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)
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.is_empty() {
234                return Err(Error::UnexpectedResponse(
235                    "allocate_external_party returned no party id".to_string(),
236                ));
237            }
238            Ok(response.party_id)
239        })
240        .await
241    }
242
243    /// List one page of known parties (`ListKnownParties`). Pass the returned
244    /// token to fetch the next page; `page_size` `0` uses the server default.
245    ///
246    /// # Errors
247    /// Returns an [`Error`] if authentication/authorization or the RPC fails.
248    pub async fn list_known_parties_page(
249        &self,
250        page_size: i32,
251        page_token: Option<String>,
252    ) -> Result<(Vec<pb::PartyDetails>, Option<String>)> {
253        telemetry::instrument("list_known_parties_page", TRANSPORT_GRPC, async move {
254            self.with_retry(|| {
255                let page_token = page_token.clone().unwrap_or_default();
256                async move {
257                    let mut client = service!(self, PartyManagementServiceClient::new);
258                    let response = client
259                        .list_known_parties(pb::ListKnownPartiesRequest {
260                            page_token,
261                            page_size,
262                            ..Default::default()
263                        })
264                        .await?
265                        .into_inner();
266                    let next =
267                        (!response.next_page_token.is_empty()).then_some(response.next_page_token);
268                    Ok((response.party_details, next))
269                }
270            })
271            .await
272        })
273        .await
274    }
275
276    /// All known parties, following pagination internally (`ListKnownParties`).
277    ///
278    /// # Errors
279    /// Returns an [`Error`] if authentication/authorization or any page RPC
280    /// fails.
281    pub async fn list_known_parties(&self) -> Result<Vec<pb::PartyDetails>> {
282        telemetry::instrument("list_known_parties", TRANSPORT_GRPC, async {
283            let mut all = Vec::new();
284            let mut page_token = String::new();
285            loop {
286                let sent = page_token.clone();
287                let (parties, next) = self
288                    .with_retry(|| {
289                        let page_token = sent.clone();
290                        async move {
291                            let mut client = service!(self, PartyManagementServiceClient::new);
292                            let response = client
293                                .list_known_parties(pb::ListKnownPartiesRequest {
294                                    page_token,
295                                    ..Default::default()
296                                })
297                                .await?
298                                .into_inner();
299                            Ok((response.party_details, response.next_page_token))
300                        }
301                    })
302                    .await?;
303                all.extend(parties);
304                if next.is_empty() {
305                    break;
306                }
307                // A server that never advances the token would loop forever.
308                // Stopping quietly is no better: the caller receives a prefix
309                // of the party list with nothing to say it is a prefix, and
310                // "which parties exist" is a question whose wrong answer looks
311                // exactly like a right one. So this fails.
312                if next == sent {
313                    return Err(Error::UnexpectedResponse(format!(
314                        "the participant repeated the same page token after {} parties; \
315                         the list is incomplete and cannot be continued",
316                        all.len()
317                    )));
318                }
319                page_token = next;
320            }
321            Ok(all)
322        })
323        .await
324    }
325
326    /// Details for specific parties (`GetParties`). Unknown parties are silently
327    /// omitted from the result rather than erroring.
328    ///
329    /// # Errors
330    /// Returns an [`Error`] if authentication/authorization or the RPC fails.
331    pub async fn get_parties(&self, parties: Vec<String>) -> Result<Vec<pb::PartyDetails>> {
332        telemetry::instrument("get_parties", TRANSPORT_GRPC, async move {
333            self.with_retry(|| {
334                let parties = parties.clone();
335                async move {
336                    let mut client = service!(self, PartyManagementServiceClient::new);
337                    Ok(client
338                        .get_parties(pb::GetPartiesRequest {
339                            parties,
340                            ..Default::default()
341                        })
342                        .await?
343                        .into_inner()
344                        .party_details)
345                }
346            })
347            .await
348        })
349        .await
350    }
351
352    // ---- UserManagementService (self-inspect) ------------------------------
353
354    /// The authenticated user's own record (`GetUser` with an empty user id).
355    /// Authorized for any authenticated token (no admin right needed).
356    ///
357    /// # Errors
358    /// Returns an [`Error`] if the RPC fails or the response carries no user.
359    pub async fn current_user(&self) -> Result<pb::User> {
360        self.get_user("").await
361    }
362
363    /// A user's record by id (`GetUser`); an empty `user_id` means the
364    /// authenticated user.
365    ///
366    /// # Errors
367    /// Returns an [`Error`] if the RPC fails or the response carries no user.
368    pub async fn get_user(&self, user_id: &str) -> Result<pb::User> {
369        let user_id = user_id.to_string();
370        telemetry::instrument("get_user", TRANSPORT_GRPC, async move {
371            self.with_retry(|| {
372                let user_id = user_id.clone();
373                async move {
374                    let mut client = service!(self, UserManagementServiceClient::new);
375                    let response = client
376                        .get_user(pb::GetUserRequest {
377                            user_id,
378                            ..Default::default()
379                        })
380                        .await?
381                        .into_inner();
382                    response.user.ok_or_else(|| {
383                        Error::UnexpectedResponse("get_user returned no user".to_string())
384                    })
385                }
386            })
387            .await
388        })
389        .await
390    }
391
392    /// The authenticated user's own rights (`ListUserRights` with an empty user
393    /// id). Authorized for any authenticated token.
394    ///
395    /// # Errors
396    /// Returns an [`Error`] if the RPC fails.
397    pub async fn current_user_rights(&self) -> Result<Vec<pb::Right>> {
398        self.list_user_rights("").await
399    }
400
401    /// A user's rights by id (`ListUserRights`); an empty `user_id` means the
402    /// authenticated user.
403    ///
404    /// # Errors
405    /// Returns an [`Error`] if the RPC fails.
406    pub async fn list_user_rights(&self, user_id: &str) -> Result<Vec<pb::Right>> {
407        let user_id = user_id.to_string();
408        telemetry::instrument("list_user_rights", TRANSPORT_GRPC, async move {
409            self.with_retry(|| {
410                let user_id = user_id.clone();
411                async move {
412                    let mut client = service!(self, UserManagementServiceClient::new);
413                    Ok(client
414                        .list_user_rights(pb::ListUserRightsRequest {
415                            user_id,
416                            ..Default::default()
417                        })
418                        .await?
419                        .into_inner()
420                        .rights)
421                }
422            })
423            .await
424        })
425        .await
426    }
427
428    // ---- PackageService (read) ----------------------------------------------
429
430    /// The ids of all Daml packages known to the participant
431    /// (`PackageService.ListPackages`).
432    ///
433    /// # Errors
434    /// Returns an [`Error`] if authentication or the RPC fails.
435    pub async fn list_packages(&self) -> Result<Vec<String>> {
436        telemetry::instrument("list_packages", TRANSPORT_GRPC, async {
437            self.with_retry(|| async {
438                let mut client = service!(self, PackageServiceClient::new);
439                Ok(client
440                    .list_packages(lapi::ListPackagesRequest {})
441                    .await?
442                    .into_inner()
443                    .package_ids)
444            })
445            .await
446        })
447        .await
448    }
449
450    /// The status of a package on the participant
451    /// (`PackageService.GetPackageStatus`): `Registered` if the package is
452    /// known, `Unspecified` otherwise.
453    ///
454    /// # Errors
455    /// Download a package the participant knows, as `ArchivePayload` bytes.
456    ///
457    /// This is what `canton_lf::decode_payload` reads — the *inner* half of an
458    /// archive. A DAR entry is the whole `Archive`, payload and hash together,
459    /// and `canton_lf::decode_package` reads that one; the Ledger API returns
460    /// the parts separately, so the two are different bytes and each has its
461    /// own decoder.
462    ///
463    /// Bindings can therefore be generated from what a participant has actually
464    /// vetted, rather than from a DAR file that has to be found somewhere — and
465    /// for a package that only ever existed on a network, there may be no such
466    /// file at all.
467    ///
468    /// The payload is hashed and the digest checked against `package_id` before the bytes are
469    /// handed back. A package id **is** the hash of its payload, so that check
470    /// is what makes asking by id pin the content exactly, with nothing further
471    /// to checksum.
472    ///
473    /// # Errors
474    /// Returns an [`Error`] if the RPC fails, and
475    /// [`Error::UnexpectedResponse`] if the participant answers with no payload
476    /// or whose bytes do not hash to the id that was asked for.
477    pub async fn get_package(&self, package_id: &str) -> Result<Vec<u8>> {
478        let package_id = package_id.to_string();
479        let package_for_check = package_id.clone();
480        telemetry::instrument("get_package", TRANSPORT_GRPC, async move {
481            let response = self
482                .with_retry(|| {
483                    let package_id = package_id.clone();
484                    async move {
485                        let mut client = service!(self, PackageServiceClient::new);
486                        Ok(client
487                            .get_package(lapi::GetPackageRequest { package_id })
488                            .await?
489                            .into_inner())
490                    }
491                })
492                .await?;
493            if response.archive_payload.is_empty() {
494                return Err(Error::UnexpectedResponse(
495                    "the participant returned an empty archive".to_string(),
496                ));
497            }
498            // A package id *is* the SHA-256 of its payload, so the check is to
499            // hash the bytes — not to read the `hash` field back. That field is
500            // the server's own claim about what it sent, so comparing it to the
501            // id proves only that the server is self-consistent, and an empty
502            // one skipped the check altogether. Hashing the payload is what
503            // makes asking by id pin the content.
504            let computed = hex_sha256(&response.archive_payload);
505            if computed != package_for_check {
506                return Err(Error::UnexpectedResponse(format!(
507                    "asked for package {package_for_check} and got {} bytes hashing to {computed}",
508                    response.archive_payload.len()
509                )));
510            }
511            Ok(response.archive_payload)
512        })
513        .await
514    }
515
516    /// Returns an [`Error`] if authentication or the RPC fails.
517    pub async fn get_package_status(&self, package_id: &str) -> Result<lapi::PackageStatus> {
518        let package_id = package_id.to_string();
519        telemetry::instrument("get_package_status", TRANSPORT_GRPC, async move {
520            self.with_retry(|| {
521                let package_id = package_id.clone();
522                async move {
523                    let mut client = service!(self, PackageServiceClient::new);
524                    let response = client
525                        .get_package_status(lapi::GetPackageStatusRequest { package_id })
526                        .await?
527                        .into_inner();
528                    Ok(response.package_status())
529                }
530            })
531            .await
532        })
533        .await
534    }
535
536    /// The specific parties the authenticated user may act as — the `CanActAs`
537    /// subset of [`Self::current_user_rights`]. Supersedes the ad-hoc party
538    /// discovery used in early tests.
539    ///
540    /// Note: a user granted the wildcard `CanActAsAnyParty` right may act as
541    /// *any* party yet carries no explicit `CanActAs` entries, so this returns
542    /// an empty list for such users — an empty result means "no specific
543    /// parties", not necessarily "cannot act". Inspect
544    /// [`Self::current_user_rights`] directly if you need the wildcard.
545    ///
546    /// # Errors
547    /// Returns an [`Error`] if the underlying rights RPC fails.
548    pub async fn acting_parties(&self) -> Result<Vec<String>> {
549        let rights = self.current_user_rights().await?;
550        Ok(rights
551            .into_iter()
552            .filter_map(|right| match right.kind {
553                Some(pb::right::Kind::CanActAs(can)) => Some(can.party),
554                _ => None,
555            })
556            .collect())
557    }
558}
559
560/// The lowercase hex SHA-256 of `bytes` — which, for a Daml-LF package
561/// payload, *is* its package id.
562fn hex_sha256(bytes: &[u8]) -> String {
563    use sha2::{Digest as _, Sha256};
564    Sha256::digest(bytes)
565        .iter()
566        .fold(String::with_capacity(64), |mut out, byte| {
567            use std::fmt::Write as _;
568            let _ = write!(out, "{byte:02x}");
569            out
570        })
571}
572
573#[cfg(test)]
574#[allow(clippy::unwrap_used, clippy::expect_used)]
575mod package_integrity_tests {
576    use super::hex_sha256;
577
578    /// The known-answer test, so the helper is pinned to SHA-256 rather than to
579    /// whatever it currently computes.
580    #[test]
581    fn the_digest_is_sha256_in_lowercase_hex() {
582        assert_eq!(
583            hex_sha256(b""),
584            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
585        );
586        assert_eq!(
587            hex_sha256(b"abc"),
588            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
589        );
590    }
591
592    /// The property `get_package` relies on: the committed V2 corpus is named
593    /// by the id its bytes hash to, and that is what makes asking by id pin the
594    /// content. If this helper ever stopped being SHA-256, those names would
595    /// stop meaning anything and nothing else would notice.
596    #[test]
597    fn a_committed_payload_hashes_to_the_id_in_its_name() {
598        let dir = concat!(
599            env!("CARGO_MANIFEST_DIR"),
600            "/../../testdata/token-standard-v2"
601        );
602        let Ok(entries) = std::fs::read_dir(dir) else {
603            return; // not checked out — the drift guard covers this corpus too
604        };
605        let mut checked = 0;
606        for entry in entries.flatten() {
607            let name = entry.file_name().to_string_lossy().to_string();
608            let Some(stem) = name.strip_suffix(".lfpayload") else {
609                continue;
610            };
611            let Some((_, id)) = stem.rsplit_once('-') else {
612                continue;
613            };
614            let bytes = std::fs::read(entry.path()).expect("the payload reads");
615            assert_eq!(hex_sha256(&bytes), id, "{name} does not hash to its own id");
616            checked += 1;
617        }
618        assert!(checked >= 9, "expected the V2 corpus, checked {checked}");
619    }
620}