canton_admin/client.rs
1//! Ledger-API admin client: party management + user self-inspect.
2
3use std::sync::Arc;
4
5use canton_core::auth::{self, Intercepted};
6use canton_core::telemetry::{self, TRANSPORT_GRPC};
7use canton_core::{Config, Error, Result};
8use canton_proto::com::daml::ledger::api::v2 as lapi;
9use canton_proto::com::daml::ledger::api::v2::admin as pb;
10use lapi::package_service_client::PackageServiceClient;
11use pb::party_management_service_client::PartyManagementServiceClient;
12use pb::user_management_service_client::UserManagementServiceClient;
13use tonic::transport::Channel;
14
15/// A client for Canton's Ledger-API **admin** services: `PartyManagementService`
16/// and `UserManagementService`.
17///
18/// Served on the Ledger API port (e.g. `:3901`) — the same endpoint and auth as
19/// [`canton_ledger`]. Party-management RPCs require the `ParticipantAdmin`
20/// right; the `current_user*` self-inspect calls do not.
21///
22/// [`canton_ledger`]: https://docs.rs/canton-ledger
23#[derive(Clone, Debug)]
24pub struct AdminClient {
25 channel: Channel,
26 config: Arc<Config>,
27}
28
29/// Build a gRPC service client on the authenticated channel, with this
30/// client's decode limit applied.
31///
32/// A macro rather than a function because `max_decoding_message_size` is an
33/// inherent method on each generated client — tonic exposes no trait for it —
34/// so there is nothing to be generic over. Keeping every construction site
35/// behind one expansion is the point: `tonic`'s 4 MiB default is small enough
36/// that a real ACS page trips it, and a new RPC added later would otherwise
37/// pick the default up silently.
38macro_rules! service {
39 ($self:ident, $ctor:expr) => {
40 $ctor($self.intercepted().await?)
41 .max_decoding_message_size($self.config.max_decoding_message_size())
42 };
43}
44
45impl AdminClient {
46 /// Build a lazily-connected client. Returns immediately; the TCP/TLS
47 /// handshake happens on the first RPC.
48 ///
49 /// # Errors
50 /// Returns [`Error::InvalidRequest`] if the endpoint is not a valid URI.
51 pub fn connect_lazy(config: Config) -> Result<Self> {
52 Ok(Self {
53 channel: config.connect_channel()?,
54 config: Arc::new(config),
55 })
56 }
57
58 /// A channel wrapped with a fresh bearer-token interceptor for this call.
59 async fn intercepted(&self) -> Result<Intercepted> {
60 auth::intercepted(&self.channel, self.config.auth()).await
61 }
62
63 /// Run a unary operation under the configured retry policy (no-op when
64 /// retrying is not enabled).
65 async fn with_retry<T, F, Fut>(&self, op: F) -> Result<T>
66 where
67 F: FnMut() -> Fut,
68 Fut: std::future::Future<Output = Result<T>>,
69 {
70 canton_core::retry::run_with_retry(self.config.retry(), op).await
71 }
72
73 // ---- PartyManagementService --------------------------------------------
74
75 /// The participant's id (`GetParticipantId`). Works with any authenticated
76 /// token.
77 ///
78 /// # Errors
79 /// Returns an [`Error`] if the RPC fails.
80 pub async fn participant_id(&self) -> Result<String> {
81 telemetry::instrument("participant_id", TRANSPORT_GRPC, async {
82 self.with_retry(|| async {
83 let mut client = service!(self, PartyManagementServiceClient::new);
84 Ok(client
85 .get_participant_id(pb::GetParticipantIdRequest {})
86 .await?
87 .into_inner()
88 .participant_id)
89 })
90 .await
91 })
92 .await
93 }
94
95 /// Allocate a new party (`AllocateParty`). `party_id_hint` suggests the
96 /// local part of the party id; the participant returns the actual
97 /// [`PartyDetails`](pb::PartyDetails) (`party = "<hint>::<fingerprint>"`).
98 ///
99 /// Requires the `ParticipantAdmin` right. Not retried: party allocation is
100 /// a non-idempotent topology mutation (a lost response must not double
101 /// allocate), so a transient failure surfaces to the caller.
102 ///
103 /// # Errors
104 /// Returns an [`Error`] if authentication/authorization fails, the RPC
105 /// fails, or the response carries no party details.
106 pub async fn allocate_party(&self, party_id_hint: Option<&str>) -> Result<pb::PartyDetails> {
107 let party_id_hint = party_id_hint.unwrap_or_default().to_string();
108 telemetry::instrument("allocate_party", TRANSPORT_GRPC, async move {
109 let mut client = service!(self, PartyManagementServiceClient::new);
110 let response = client
111 .allocate_party(pb::AllocatePartyRequest {
112 party_id_hint,
113 ..Default::default()
114 })
115 .await?
116 .into_inner();
117 response.party_details.ok_or_else(|| {
118 Error::UnexpectedResponse("allocate_party returned no party details".to_string())
119 })
120 })
121 .await
122 }
123
124 /// Ask the participant what allocating an external party for `public_key`
125 /// would look like, without allocating it.
126 ///
127 /// The first half of external-party onboarding. The participant computes
128 /// the key's fingerprint and the party id derived from it, builds the
129 /// onboarding topology transactions, and returns one hash over them. None
130 /// of it is submitted: that is [`allocate_external_party`], after the key
131 /// has signed the hash.
132 ///
133 /// [`allocate_external_party`]: Self::allocate_external_party
134 ///
135 /// # Errors
136 /// Returns an [`Error`] if the RPC fails, or [`Error::UnexpectedResponse`]
137 /// if the participant answers without a hash to sign or without the
138 /// fingerprint — either of which would leave the caller unable to sign as
139 /// this party at all.
140 pub async fn generate_external_party_topology(
141 &self,
142 synchronizer: &str,
143 party_hint: &str,
144 public_key: &canton_signer::PublicKey,
145 ) -> Result<crate::external_party::ExternalPartyTopology> {
146 let request = pb::GenerateExternalPartyTopologyRequest {
147 synchronizer: synchronizer.to_string(),
148 party_hint: party_hint.to_string(),
149 public_key: Some(public_key.into()),
150 ..Default::default()
151 };
152 telemetry::instrument(
153 "generate_external_party_topology",
154 TRANSPORT_GRPC,
155 async move {
156 let response = self
157 .with_retry(|| {
158 let request = request.clone();
159 async move {
160 let mut client = service!(self, PartyManagementServiceClient::new);
161 Ok(client
162 .generate_external_party_topology(request)
163 .await?
164 .into_inner())
165 }
166 })
167 .await?;
168 crate::external_party::ExternalPartyTopology::from_response(response, public_key)
169 },
170 )
171 .await
172 }
173
174 /// Allocate the external party described by `topology`, authorized by
175 /// `signer`.
176 ///
177 /// The second half. The signature is over
178 /// [`multi_hash`](crate::external_party::ExternalPartyTopology::multi_hash)
179 /// — one hash covering every onboarding transaction, so one signature
180 /// authorizes the whole set — and it is what proves the party controls the
181 /// key being registered for it.
182 ///
183 /// `user_id` gets `act_as` rights on the new party; empty grants none.
184 ///
185 /// Returns the allocated party id. This waits for the allocation to reach
186 /// the synchronizer, so the party is usable when it returns.
187 ///
188 /// # Errors
189 /// Returns an [`Error`] if signing fails or the RPC does. A signature made
190 /// by a key other than the one in the topology is rejected by the
191 /// participant, not here.
192 pub async fn allocate_external_party(
193 &self,
194 synchronizer: &str,
195 topology: &crate::external_party::ExternalPartyTopology,
196 signer: &dyn canton_signer::Signer,
197 user_id: &str,
198 ) -> Result<String> {
199 let signature = signer.sign(topology.multi_hash()).await?;
200 let request = pb::AllocateExternalPartyRequest {
201 synchronizer: synchronizer.to_string(),
202 onboarding_transactions: topology
203 .transactions()
204 .iter()
205 .map(
206 |transaction| pb::allocate_external_party_request::SignedTransaction {
207 transaction: transaction.clone(),
208 // The multi-hash signature below covers every transaction,
209 // so none needs one of its own.
210 signatures: Vec::new(),
211 },
212 )
213 .collect(),
214 multi_hash_signatures: vec![(&signature).into()],
215 identity_provider_id: String::new(),
216 // The party is not usable until it is on the synchronizer, and a
217 // caller that just allocated it is about to use it.
218 wait_for_allocation: Some(true),
219 user_id: user_id.to_string(),
220 };
221 telemetry::instrument("allocate_external_party", TRANSPORT_GRPC, async move {
222 // Deliberately not retried, exactly as `allocate_party` is not.
223 // `AllocateExternalPartyRequest` carries no command or submission
224 // id, so there is no idempotency key that would make a second
225 // attempt a duplicate — and `wait_for_allocation` makes the call
226 // long enough that losing the response to a committed allocation is
227 // realistic. Retrying would be a guess about Canton's behaviour
228 // dressed up as resilience.
229 let response = {
230 let mut client = service!(self, PartyManagementServiceClient::new);
231 client.allocate_external_party(request).await?.into_inner()
232 };
233 if response.party_id != topology.party_id() {
234 return Err(Error::UnexpectedResponse(format!(
235 "allocate_external_party allocated `{}`, not the `{}` that was signed for",
236 response.party_id,
237 topology.party_id()
238 )));
239 }
240 if response.party_id.is_empty() {
241 return Err(Error::UnexpectedResponse(
242 "allocate_external_party returned no party id".to_string(),
243 ));
244 }
245 Ok(response.party_id)
246 })
247 .await
248 }
249
250 /// List one page of known parties (`ListKnownParties`). Pass the returned
251 /// token to fetch the next page; `page_size` `0` uses the server default.
252 ///
253 /// # Errors
254 /// Returns an [`Error`] if authentication/authorization or the RPC fails.
255 pub async fn list_known_parties_page(
256 &self,
257 page_size: i32,
258 page_token: Option<String>,
259 ) -> Result<(Vec<pb::PartyDetails>, Option<String>)> {
260 telemetry::instrument("list_known_parties_page", TRANSPORT_GRPC, async move {
261 self.with_retry(|| {
262 let page_token = page_token.clone().unwrap_or_default();
263 async move {
264 let mut client = service!(self, PartyManagementServiceClient::new);
265 let response = client
266 .list_known_parties(pb::ListKnownPartiesRequest {
267 page_token,
268 page_size,
269 ..Default::default()
270 })
271 .await?
272 .into_inner();
273 let next =
274 (!response.next_page_token.is_empty()).then_some(response.next_page_token);
275 Ok((response.party_details, next))
276 }
277 })
278 .await
279 })
280 .await
281 }
282
283 /// All known parties, following pagination internally (`ListKnownParties`).
284 ///
285 /// # Errors
286 /// Returns an [`Error`] if authentication/authorization or any page RPC
287 /// fails.
288 pub async fn list_known_parties(&self) -> Result<Vec<pb::PartyDetails>> {
289 telemetry::instrument("list_known_parties", TRANSPORT_GRPC, async {
290 let mut all = Vec::new();
291 let mut page_token = String::new();
292 let mut seen = std::collections::HashSet::new();
293 for page in 1.. {
294 let sent = page_token.clone();
295 let (parties, next) = self
296 .with_retry(|| {
297 let page_token = sent.clone();
298 async move {
299 let mut client = service!(self, PartyManagementServiceClient::new);
300 let response = client
301 .list_known_parties(pb::ListKnownPartiesRequest {
302 page_token,
303 ..Default::default()
304 })
305 .await?
306 .into_inner();
307 Ok((response.party_details, response.next_page_token))
308 }
309 })
310 .await?;
311 all.extend(parties);
312 if next.is_empty() {
313 break;
314 }
315 // A server that never advances the token, cycles between a
316 // few, or mints a fresh one forever would have this loop run
317 // until memory ran out. Stopping quietly is no better: the
318 // caller receives a prefix of the party list with nothing to
319 // say it is a prefix, and "which parties exist" is a question
320 // whose wrong answer looks exactly like a right one. So a
321 // token seen before, or more pages than any participant has,
322 // fails.
323 if next == sent || !seen.insert(next.clone()) {
324 return Err(Error::UnexpectedResponse(format!(
325 "the participant repeated a page token after {} parties; \
326 the list is incomplete and cannot be continued",
327 all.len()
328 )));
329 }
330 if page >= canton_core::MAX_LIST_PAGES {
331 return Err(Error::UnexpectedResponse(format!(
332 "the party listing did not end after {} pages and {} parties; \
333 the list is incomplete and cannot be continued",
334 canton_core::MAX_LIST_PAGES,
335 all.len()
336 )));
337 }
338 page_token = next;
339 }
340 Ok(all)
341 })
342 .await
343 }
344
345 /// Details for specific parties (`GetParties`). Unknown parties are silently
346 /// omitted from the result rather than erroring.
347 ///
348 /// # Errors
349 /// Returns an [`Error`] if authentication/authorization or the RPC fails.
350 pub async fn get_parties(&self, parties: Vec<String>) -> Result<Vec<pb::PartyDetails>> {
351 telemetry::instrument("get_parties", TRANSPORT_GRPC, async move {
352 self.with_retry(|| {
353 let parties = parties.clone();
354 async move {
355 let mut client = service!(self, PartyManagementServiceClient::new);
356 Ok(client
357 .get_parties(pb::GetPartiesRequest {
358 parties,
359 ..Default::default()
360 })
361 .await?
362 .into_inner()
363 .party_details)
364 }
365 })
366 .await
367 })
368 .await
369 }
370
371 // ---- UserManagementService (self-inspect) ------------------------------
372
373 /// The authenticated user's own record (`GetUser` with an empty user id).
374 /// Authorized for any authenticated token (no admin right needed).
375 ///
376 /// # Errors
377 /// Returns an [`Error`] if the RPC fails or the response carries no user.
378 pub async fn current_user(&self) -> Result<pb::User> {
379 self.get_user("").await
380 }
381
382 /// A user's record by id (`GetUser`); an empty `user_id` means the
383 /// authenticated user.
384 ///
385 /// # Errors
386 /// Returns an [`Error`] if the RPC fails or the response carries no user.
387 pub async fn get_user(&self, user_id: &str) -> Result<pb::User> {
388 let user_id = user_id.to_string();
389 telemetry::instrument("get_user", TRANSPORT_GRPC, async move {
390 self.with_retry(|| {
391 let user_id = user_id.clone();
392 async move {
393 let mut client = service!(self, UserManagementServiceClient::new);
394 let response = client
395 .get_user(pb::GetUserRequest {
396 user_id,
397 ..Default::default()
398 })
399 .await?
400 .into_inner();
401 response.user.ok_or_else(|| {
402 Error::UnexpectedResponse("get_user returned no user".to_string())
403 })
404 }
405 })
406 .await
407 })
408 .await
409 }
410
411 /// The authenticated user's own rights (`ListUserRights` with an empty user
412 /// id). Authorized for any authenticated token.
413 ///
414 /// # Errors
415 /// Returns an [`Error`] if the RPC fails.
416 pub async fn current_user_rights(&self) -> Result<Vec<pb::Right>> {
417 self.list_user_rights("").await
418 }
419
420 /// A user's rights by id (`ListUserRights`); an empty `user_id` means the
421 /// authenticated user.
422 ///
423 /// # Errors
424 /// Returns an [`Error`] if the RPC fails.
425 pub async fn list_user_rights(&self, user_id: &str) -> Result<Vec<pb::Right>> {
426 let user_id = user_id.to_string();
427 telemetry::instrument("list_user_rights", TRANSPORT_GRPC, async move {
428 self.with_retry(|| {
429 let user_id = user_id.clone();
430 async move {
431 let mut client = service!(self, UserManagementServiceClient::new);
432 Ok(client
433 .list_user_rights(pb::ListUserRightsRequest {
434 user_id,
435 ..Default::default()
436 })
437 .await?
438 .into_inner()
439 .rights)
440 }
441 })
442 .await
443 })
444 .await
445 }
446
447 // ---- PackageService (read) ----------------------------------------------
448
449 /// The ids of all Daml packages known to the participant
450 /// (`PackageService.ListPackages`).
451 ///
452 /// # Errors
453 /// Returns an [`Error`] if authentication or the RPC fails.
454 pub async fn list_packages(&self) -> Result<Vec<String>> {
455 telemetry::instrument("list_packages", TRANSPORT_GRPC, async {
456 self.with_retry(|| async {
457 let mut client = service!(self, PackageServiceClient::new);
458 Ok(client
459 .list_packages(lapi::ListPackagesRequest {})
460 .await?
461 .into_inner()
462 .package_ids)
463 })
464 .await
465 })
466 .await
467 }
468
469 /// The status of a package on the participant
470 /// (`PackageService.GetPackageStatus`): `Registered` if the package is
471 /// known, `Unspecified` otherwise.
472 ///
473 /// # Errors
474 /// Download a package the participant knows, as `ArchivePayload` bytes.
475 ///
476 /// This is what `canton_lf::decode_payload` reads — the *inner* half of an
477 /// archive. A DAR entry is the whole `Archive`, payload and hash together,
478 /// and `canton_lf::decode_package` reads that one; the Ledger API returns
479 /// the parts separately, so the two are different bytes and each has its
480 /// own decoder.
481 ///
482 /// Bindings can therefore be generated from what a participant has actually
483 /// vetted, rather than from a DAR file that has to be found somewhere — and
484 /// for a package that only ever existed on a network, there may be no such
485 /// file at all.
486 ///
487 /// The payload is hashed and the digest checked against `package_id` before the bytes are
488 /// handed back. A package id **is** the hash of its payload, so that check
489 /// is what makes asking by id pin the content exactly, with nothing further
490 /// to checksum.
491 ///
492 /// # Errors
493 /// Returns an [`Error`] if the RPC fails, and
494 /// [`Error::UnexpectedResponse`] if the participant answers with no payload
495 /// or whose bytes do not hash to the id that was asked for.
496 pub async fn get_package(&self, package_id: &str) -> Result<Vec<u8>> {
497 let package_id = package_id.to_string();
498 let package_for_check = package_id.clone();
499 telemetry::instrument("get_package", TRANSPORT_GRPC, async move {
500 let response = self
501 .with_retry(|| {
502 let package_id = package_id.clone();
503 async move {
504 let mut client = service!(self, PackageServiceClient::new);
505 Ok(client
506 .get_package(lapi::GetPackageRequest { package_id })
507 .await?
508 .into_inner())
509 }
510 })
511 .await?;
512 if response.archive_payload.is_empty() {
513 return Err(Error::UnexpectedResponse(
514 "the participant returned an empty archive".to_string(),
515 ));
516 }
517 // A package id *is* the SHA-256 of its payload, so the check is to
518 // hash the bytes — not to read the `hash` field back. That field is
519 // the server's own claim about what it sent, so comparing it to the
520 // id proves only that the server is self-consistent, and an empty
521 // one skipped the check altogether. Hashing the payload is what
522 // makes asking by id pin the content.
523 let computed = hex_sha256(&response.archive_payload);
524 if computed != package_for_check {
525 return Err(Error::UnexpectedResponse(format!(
526 "asked for package {package_for_check} and got {} bytes hashing to {computed}",
527 response.archive_payload.len()
528 )));
529 }
530 Ok(response.archive_payload)
531 })
532 .await
533 }
534
535 /// Returns an [`Error`] if authentication or the RPC fails.
536 pub async fn get_package_status(&self, package_id: &str) -> Result<lapi::PackageStatus> {
537 let package_id = package_id.to_string();
538 telemetry::instrument("get_package_status", TRANSPORT_GRPC, async move {
539 self.with_retry(|| {
540 let package_id = package_id.clone();
541 async move {
542 let mut client = service!(self, PackageServiceClient::new);
543 let response = client
544 .get_package_status(lapi::GetPackageStatusRequest { package_id })
545 .await?
546 .into_inner();
547 Ok(response.package_status())
548 }
549 })
550 .await
551 })
552 .await
553 }
554
555 /// The specific parties the authenticated user may act as — the `CanActAs`
556 /// subset of [`Self::current_user_rights`]. Supersedes the ad-hoc party
557 /// discovery used in early tests.
558 ///
559 /// Note: a user granted the wildcard `CanActAsAnyParty` right may act as
560 /// *any* party yet carries no explicit `CanActAs` entries, so this returns
561 /// an empty list for such users — an empty result means "no specific
562 /// parties", not necessarily "cannot act". Inspect
563 /// [`Self::current_user_rights`] directly if you need the wildcard.
564 ///
565 /// # Errors
566 /// Returns an [`Error`] if the underlying rights RPC fails.
567 pub async fn acting_parties(&self) -> Result<Vec<String>> {
568 let rights = self.current_user_rights().await?;
569 Ok(rights
570 .into_iter()
571 .filter_map(|right| match right.kind {
572 Some(pb::right::Kind::CanActAs(can)) => Some(can.party),
573 _ => None,
574 })
575 .collect())
576 }
577}
578
579/// The lowercase hex SHA-256 of `bytes` — which, for a Daml-LF package
580/// payload, *is* its package id.
581fn hex_sha256(bytes: &[u8]) -> String {
582 use sha2::{Digest as _, Sha256};
583 Sha256::digest(bytes)
584 .iter()
585 .fold(String::with_capacity(64), |mut out, byte| {
586 use std::fmt::Write as _;
587 let _ = write!(out, "{byte:02x}");
588 out
589 })
590}
591
592#[cfg(test)]
593#[allow(clippy::unwrap_used, clippy::expect_used)]
594mod package_integrity_tests {
595 use super::hex_sha256;
596
597 /// The known-answer test, so the helper is pinned to SHA-256 rather than to
598 /// whatever it currently computes.
599 #[test]
600 fn the_digest_is_sha256_in_lowercase_hex() {
601 assert_eq!(
602 hex_sha256(b""),
603 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
604 );
605 assert_eq!(
606 hex_sha256(b"abc"),
607 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
608 );
609 }
610
611 /// The property `get_package` relies on: the committed V2 corpus is named
612 /// by the id its bytes hash to, and that is what makes asking by id pin the
613 /// content. If this helper ever stopped being SHA-256, those names would
614 /// stop meaning anything and nothing else would notice.
615 #[test]
616 fn a_committed_payload_hashes_to_the_id_in_its_name() {
617 let dir = concat!(
618 env!("CARGO_MANIFEST_DIR"),
619 "/../../testdata/token-standard-v2"
620 );
621 let Ok(entries) = std::fs::read_dir(dir) else {
622 return; // not checked out — the drift guard covers this corpus too
623 };
624 let mut checked = 0;
625 for entry in entries.flatten() {
626 let name = entry.file_name().to_string_lossy().to_string();
627 let Some(stem) = name.strip_suffix(".lfpayload") else {
628 continue;
629 };
630 let Some((_, id)) = stem.rsplit_once('-') else {
631 continue;
632 };
633 let bytes = std::fs::read(entry.path()).expect("the payload reads");
634 assert_eq!(hex_sha256(&bytes), id, "{name} does not hash to its own id");
635 checked += 1;
636 }
637 assert!(checked >= 9, "expected the V2 corpus, checked {checked}");
638 }
639}