Skip to main content

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}