Multi-tenant ACME providers are difficult to pick: the picker offers providers from every tenant and the label shows the CA account id

We run a multi-tenant deployment with one ACME provider per tenant. Two things make ACME providers hard to work with at that scale: you cannot tell them apart in a list, and when configuring a domain you are offered providers that cannot be selected; it will generate an error.

1. The ACME provider picker is not scoped to the domain’s tenant

This is the most disruptive one. When enabling automatic certificate management on a domain, the acmeProviderId picker lists every ACME provider on the server, including those belonging to other tenants. Selecting one of those looks perfectly valid until you press Save, which then fails with an invalidForeignKey error because the provider is not in the domain’s tenant.

The rejection is server-enforced and correct — crates/store/src/registry/write.rs derives the tenant from the object being written and requires every foreign key to resolve within it:

let tenant_id = object.inner.member_tenant_id().map(|id| id.id());
...
} else if let Some(tenant_id) = tenant_id
    && (object_flags & OBJ_FILTER_TENANT) != 0
    && !self.0.store.key_exists(... Property::MemberTenantId ... IndexValue::U64(tenant_id) ...).await?
{
    return Ok(RegistryWriteResult::InvalidForeignKey { object_id: *foreign_id });
}

So the constraint is known to the server, but never communicated to the UI. The picker’s field definition in the shipped WebUI schema (resources/schema/schema.json.gz) carries only the target object type, with no way to express a scope:

"/fields/x:CertificateManagementProperties/properties/acmeProviderId": {
  "description": "Identifier for the ACME provider managing certificates for this domain",
  "type": { "type": "objectId", "objectName": "x:AcmeProvider" },
  "update": "mutable"
}

Request: only offer selectable providers — i.e. scope the picker to the tenant of the domain being edited. Everything needed for this already exists server-side: AcmeProvider is flagged OBJ_FILTER_TENANT and memberTenantId is already an indexed, queryable filter on it (IndexSchema::new(Property::MemberTenantId, IndexSchemaType::Search, IndexSchemaValueType::Id)), so an AcmeProvider/query filtered by the domain’s memberTenantId returns exactly the valid way for an objectId field to declare that scope — something like a filter / scopeByhint ("filter": { "memberTenantId": "$memberTenantId" }) that the WebUI passes into the query when populating the picker.

This generalises beyond ACME: any tenant-filtered objectId reference has the same failure mode, where an unselectable option is offered and only rejected at save time.

2. ACME providers have no user-settable name

AcmeProvider (crates/registry/src/schema/structs.rs) has no name or description field of its own. description is serverSet and client writes are rejected (Some(Property::Description) => pointer.assert_server_set()), so operators cannot label their own providers at all, and erver derives is a guess about what we care about.

Request: a mutable, optional description (or name) on AcmeProvider, used as the back to the derived label when empty. We would name ours Tenant: acme.example.com and the whole class of problem disappears. This needs a pickle version bump and the schema field flipped to update: mutable.

3. The derived label shows the CA account id, not the contact address

The label has changed three times:

  1. Before 0.16.17 — every provider showed only the directory URL. Identical for all of them (https://acme-v02.api.letsencrypt.org/directory), so telling them apart was impossible.
  2. 0.16.17 — the CHANGELOG entry reads *"ACME registry: Use description as label prded the contact address, which worked perfectly for us: we set each provider’s contact topostmaster@<tenant-domain>, making the list self-documenting.
  3. Current — the label shows the directory URL plus what looks like a random identifi account number, which carries no meaning for an operator.

crates/jmap/src/registry/get.rs builds description from accountUri:

let mut description = obj.directory.clone();
let account = obj
    .account_uri
    .rsplit('/')
    .find(|segment| !segment.is_empty())
    .unwrap_or(obj.account_uri.as_str());
if !account.is_empty() {
    description.push_str(" (");
    description.push_str(account);
    description.push(')');
}

For Let’s Encrypt, accountUri is https://acme-v02.api.letsencrypt.org/acme/acct/123456s https://acme-v02.api.letsencrypt.org/directory (123456789)`.

This contradicts the shipped schema’s own documentation of the field:

"description": {
  "description": "Descriptive label for this provider, built from the directory URL and t
  "type": { "type": "string", "format": "string" },
  "update": "serverSet"
}

Request: derive the label from the contact address as documented — multiple contacts refix stripped:

https://acme-v02.api.letsencrypt.org/directory ([email protected])

If the CA account id is useful for support, it belongs in the detail view as its own fiel

4. The provider list has no tenant column

memberTenantId is already indexed and already exposed as a list filter, but is not one

"columns": [
  { "name": "challengeType", "label": "Challenge type" },
{
  "name": "contact",
  "label": "Contact Email"
}
]

Request: add a Tenant column so the list is readable at a glance for a system admtenants. (Tenant-scoped principals are already filtered by OBJ_FILTER_TENANT, so thisconcerns the system administrator’s view.) Appending the tenant name to the derived label would also help everywhere a provider is referenced from a picker, not just in the list.

Impact

In a multi-tenant deployment the ACME provider list is a set of rows distinguished only by an opaque CA account number, and the domain configuration form invites you to pick from providers that cannot be
saved. Both are trial-and-error today.

1 Like

Thought I logged a similar ticket already, but I am in support of making this more differentiatable

This has been implemented in the v1 dev branch already.