Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions doc/content/design/trusted-certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
title: Trusted certificates for identity validation in TLS connections
layout: default
design_doc: true
revision: 2
status: released (26.13.0)
revision: 3
status: released (26.13.0), with proposed changes for releases after 26.23.0
---

# Overview
Expand Down Expand Up @@ -37,6 +37,8 @@ By introducing a "purpose" attribute, certificates can be organized by their int
# Use Cases
* An XAPI client establishes a TLS connection to an XAPI service.
This case is outside the scope of trusted certificates managed by XAPI and is included here only for completeness.
* An XAPI client logs in to an XAPI service with a TLS client certificate.
This case is supported by a new purpose in this design, see [Client certificate authentication](#client-certificate-authentication).
* An XAPI process on one host initiates a TLS connection to an XAPI process on another host within the same pool.
This case is covered in the base design and is listed here for completeness.
* An XAPI process initiates a TLS connection to an external service, such as an appliance.
Expand Down Expand Up @@ -244,3 +246,25 @@ The endpoint to validate the peer's identity must unambiguously choose only one
No more attempts on remaining bundles when validation with the selected one fails (the server certificate is not trusted by the selected bundle).

For example, if "Peer Bundle" doesn't exist and the "CA Bundle" (if not empty) is selected to do the validation, the endpoint should not try with "General Bundle" even when the validation with "CA Bundle" failed.

## Client certificate authentication
A new purpose "client_certificate_auth" is added for the trusted certificates used to verify TLS client certificates.
When "pool.enable_client_certificate_auth" is called, stunnel on the pool coordinator verifies the client certificate with the "CA Bundle" of this purpose.
It also checks that the name in the client certificate matches "pool.client_certificate_auth_name".
If the check passes, the client can log in without a password. Otherwise, the regular authentication is used.

The client certificate is only checked on the coordinator. A pool member tells the client to contact the coordinator.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would reword this as:

Suggested change
The client certificate is only checked on the coordinator. A pool member tells the client to contact the coordinator.
A valid client certificate is able to login only to a pool coordinator. When trying to use it on pool members, they send a response to contact the coordinator instead.

The trusted certificates are copied to all hosts in the pool, so a new coordinator has the same bundle when the coordinator is changed.

@psafont psafont Nov 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The trusted certificates are copied to all hosts in the pool, so a new coordinator has the same bundle when the coordinator is changed.
The trusted certificates are copied to all hosts in the pool, so a new coordinator has the updated bundle when the coordinator is changed.


Only root CA certificates can be installed for this purpose, because stunnel is configured to verify the certificate chain.
A self-signed client certificate can be installed as a root CA.

Client certificate logins used to get the "client-cert" role.
This role is removed, because a client that manages a pool this way needs the same access as pool-admin.

@psafont psafont Nov 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear what needs this, I'd prefer it if it was reworded:

Suggested change
This role is removed, because a client that manages a pool this way needs the same access as pool-admin.
This role is removed, because a client that manages a pool is considered to have the pool-admin role.

These logins now get the permissions of pool-admin, so only pool-admin can install certificates for this purpose.

The "Default Bundle" is not used for client certificate authentication any more.
The CA certificates installed by "pool.install_ca_certificate" can't be used for client certificate login.

stunnel only reads the bundle when it starts or reloads. The coordinator reloads stunnel when the bundle is changed.
If the bundle is empty, client certificate authentication is not configured in stunnel.
44 changes: 7 additions & 37 deletions ocaml/idl/datamodel.ml
Original file line number Diff line number Diff line change
Expand Up @@ -8891,22 +8891,10 @@ let http_actions =
, (Get, Constants.xenops_uri, false, [], _R_VM_POWER_ADMIN, [])
)
; ( "post_services_xenops"
, ( Post
, Constants.xenops_uri
, false
, []
, _R_VM_POWER_ADMIN ++ _R_CLIENT_CERT
, []
)
, (Post, Constants.xenops_uri, false, [], _R_VM_POWER_ADMIN, [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks like just a format change, why is this required?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not only formatting: the old code had _R_VM_POWER_ADMIN ++ _R_CLIENT_CERT, and this commit drops ++ _R_CLIENT_CERT because the client-cert role is removed.

)
; ( "put_services_xenops"
, ( Put
, Constants.xenops_uri
, false
, []
, _R_VM_POWER_ADMIN ++ _R_CLIENT_CERT
, []
)
, (Put, Constants.xenops_uri, false, [], _R_VM_POWER_ADMIN, [])
)
; ( "get_services_sm"
, (Get, Constants.sm_uri, false, [], _R_VM_POWER_ADMIN, [])
Expand Down Expand Up @@ -9179,32 +9167,12 @@ let http_actions =
, (Get, Constants.get_repository_uri, false, [], _R_LOCAL_ROOT_ONLY, [])
)
; ( "get_enabled_repository"
, ( Get
, Constants.get_enabled_repository_uri
, false
, []
, _R_POOL_OP ++ _R_CLIENT_CERT
, []
)
, (Get, Constants.get_enabled_repository_uri, false, [], _R_POOL_OP, [])
)
; ( "get_host_updates"
, ( Get
, Constants.get_host_updates_uri
, false
, []
, _R_POOL_OP ++ _R_CLIENT_CERT
, []
)
)
; ( "get_updates"
, ( Get
, Constants.get_updates_uri
, true
, []
, _R_POOL_OP ++ _R_CLIENT_CERT
, []
)
, (Get, Constants.get_host_updates_uri, false, [], _R_POOL_OP, [])
)
; ("get_updates", (Get, Constants.get_updates_uri, true, [], _R_POOL_OP, []))
; ("put_bundle", (Put, Constants.put_bundle_uri, true, [], _R_POOL_OP, []))
]

Expand Down Expand Up @@ -9243,6 +9211,8 @@ let extra_permissions =
[
(Task.extra_permission_task_destroy_any, _R_POOL_OP)
(* only POOL_OP can destroy any tasks *)
; (Datamodel_pool.extra_permission_client_certificate_auth, _R_POOL_ADMIN)
(* client certificates trusted for this purpose log in as pool-admin *)
]

module StringMap = Map.Make (String)
Expand Down
4 changes: 4 additions & 0 deletions ocaml/idl/datamodel_certificate.ml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ let certificate_purpose =
, "Trusted certificates that are for LDAPS connections to external \
authentication services."
)
; ( "client_certificate_auth"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The context is already for trusted certificate, so it can be client_auth without any ambiguity.

, "Trusted root CA certificates that are for authenticating XAPI \
clients by TLS client certificates."
)
]
)

Expand Down
2 changes: 1 addition & 1 deletion ocaml/idl/datamodel_common.ml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ open Datamodel_roles
to leave a gap for potential hotfixes needing to increment the schema version.*)
let schema_major_vsn = 5

let schema_minor_vsn = 911
let schema_minor_vsn = 912

(* Historical schema versions just in case this is useful later *)
let rio_schema_major_vsn = 5
Expand Down
12 changes: 4 additions & 8 deletions ocaml/idl/datamodel_host.ml
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,7 @@ let evacuate =
; param_default= Some (VInt 0L)
}
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let get_uncooperative_resident_VMs =
call
Expand Down Expand Up @@ -473,17 +472,15 @@ let disable =
; param_default= Some (VBool true)
}
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

(* Host.Enable *)

let enable =
call ~name:"enable" ~published:rel_rio
~doc:"Puts the host into a state in which new VMs can be started."
~params:[(Ref _host, "host", "The Host to enable")]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

(* Host.Shutdown *)

Expand Down Expand Up @@ -2086,8 +2083,7 @@ let apply_updates =
, "The list of results after applying updates, including livepatch apply \
failures and recommended guidances"
)
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let rescan_drivers =
call ~name:"rescan_drivers" ~in_oss_since:None ~lifecycle:[]
Expand Down
63 changes: 23 additions & 40 deletions ocaml/idl/datamodel_pool.ml
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,11 @@ let enable_ha =
; param_default= None
}
]
~doc:"Turn on High Availability mode"
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~doc:"Turn on High Availability mode" ~allowed_roles:_R_POOL_OP ()

let disable_ha =
call ~published:rel_miami ~name:"disable_ha" ~in_oss_since:None ~params:[]
~doc:"Turn off High Availability mode"
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~doc:"Turn off High Availability mode" ~allowed_roles:_R_POOL_OP ()

let sync_database =
call ~name:"sync_database" ~in_oss_since:None ~published:rel_rio ~params:[]
Expand Down Expand Up @@ -653,7 +649,7 @@ let install_ca_certificate =
(String, "name", "A name to give the certificate")
; (String, "cert", "The certificate in PEM format")
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~allowed_roles:_R_POOL_OP
~lifecycle:
[
(Published, "1.290.0", "Install TLS CA certificate")
Expand Down Expand Up @@ -701,7 +697,7 @@ let uninstall_ca_certificate =
; param_default= Some (VBool false)
}
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~allowed_roles:_R_POOL_OP
~lifecycle:
[
(Published, "1.290.0", "Uninstall TLS CA certificate")
Expand Down Expand Up @@ -956,8 +952,7 @@ let set_repositories =
(Ref _pool, "self", "The pool")
; (Set (Ref _repository), "value", "The set of repositories to be enabled")
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let add_repository =
call ~name:"add_repository"
Expand All @@ -971,8 +966,7 @@ let add_repository =
, "The repository to be added to the enabled set"
)
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let remove_repository =
call ~name:"remove_repository"
Expand All @@ -983,8 +977,7 @@ let remove_repository =
(Ref _pool, "self", "The pool")
; (Ref _repository, "value", "The repository to be removed")
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let sync_updates =
call ~name:"sync_updates"
Expand Down Expand Up @@ -1045,8 +1038,7 @@ let sync_updates =
}
]
~result:(String, "The SHA256 hash of updateinfo.xml.gz")
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let check_update_readiness =
call ~name:"check_update_readiness"
Expand All @@ -1066,8 +1058,7 @@ let check_update_readiness =
\ not ready to update. An empty list means the pool can be \
updated."
)
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let enable_client_certificate_auth =
call ~name:"enable_client_certificate_auth"
Expand All @@ -1082,8 +1073,7 @@ let enable_client_certificate_auth =
allow authentication"
)
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let set_https_only =
call ~name:"set_https_only"
Expand All @@ -1107,8 +1097,7 @@ let disable_client_certificate_auth =
~lifecycle:[(Published, "1.318.0", "")]
~doc:"Disable client certificate authentication on the pool"
~params:[(Ref _pool, "self", "The pool")]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let configure_repository_proxy =
call ~name:"configure_repository_proxy"
Expand All @@ -1127,16 +1116,14 @@ let configure_repository_proxy =
, "The password used to authenticate with the proxy server"
)
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let disable_repository_proxy =
call ~name:"disable_repository_proxy"
~lifecycle:[(Published, "21.4.0", "")]
~doc:"Disable the proxy for RPM package repositories."
~params:[(Ref _pool, "self", "The pool")]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
()
~allowed_roles:_R_POOL_OP ()

let set_uefi_certificates =
call ~name:"set_uefi_certificates"
Expand Down Expand Up @@ -1378,8 +1365,10 @@ let install_trusted_certificate =
, "The purpose of the certificate"
)
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~lifecycle:[] ()
~allowed_roles:_R_POOL_OP ~lifecycle:[] ()

let extra_permission_client_certificate_auth =
"pool.install_trusted_certificate/client_certificate_auth"

let uninstall_trusted_certificate =
call ~name:"uninstall_trusted_certificate"
Expand All @@ -1392,8 +1381,7 @@ let uninstall_trusted_certificate =
, "The reference of the trusted certificate to be uninstalled"
)
]
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~lifecycle:[] ()
~allowed_roles:_R_POOL_OP ~lifecycle:[] ()

let sync_trusted_certificates_from =
call ~name:"sync_trusted_certificates_from"
Expand Down Expand Up @@ -1422,8 +1410,7 @@ let sync_trusted_certificates_from =
; (Bool, "ca", "true for 'ca' or false for 'pinned'")
]
~result:(Set (Ref _certificate), "The references of certificates synced.")
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~lifecycle:[] ()
~allowed_roles:_R_POOL_OP ~lifecycle:[] ()

let trusted_certs = Map (String, Set String)

Expand All @@ -1446,8 +1433,7 @@ let exchange_trusted_certificates_on_join =
)
]
~result:(trusted_certs, "The contents of these trusted TLS certificates.")
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~hide_from_docs:true ~lifecycle:[] ()
~allowed_roles:_R_POOL_OP ~hide_from_docs:true ~lifecycle:[] ()

let exchange_crls_on_join =
call ~name:"exchange_crls_on_join"
Expand All @@ -1472,8 +1458,7 @@ let exchange_crls_on_join =
( certs
, "The contents of the TLS CA-issued Certificate Revocation Lists (CRLs)."
)
~allowed_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~hide_from_docs:true ~lifecycle:[] ()
~allowed_roles:_R_POOL_OP ~hide_from_docs:true ~lifecycle:[] ()

let lldp_multicast_address =
Enum
Expand Down Expand Up @@ -1685,10 +1670,8 @@ let t =
; field ~published:rel_george ~internal_only:true ~qualifier:DynamicRO
~ty:(Ref _secret) "wlb_password"
"Password for accessing the workload balancing host"
; field
~writer_roles:(_R_POOL_OP ++ _R_CLIENT_CERT)
~published:rel_george ~qualifier:RW ~ty:Bool
~default_value:(Some (VBool false)) "wlb_enabled"
; field ~writer_roles:_R_POOL_OP ~published:rel_george ~qualifier:RW
~ty:Bool ~default_value:(Some (VBool false)) "wlb_enabled"
"true if workload balancing is enabled on the pool, false otherwise"
; field ~qualifier:RW ~ty:Bool ~default_value:(Some (VBool false))
"wlb_verify_cert"
Expand Down
Loading
Loading