Skip to content

feat(encryption): [11/N] KMS factory trait and catalog wiring#2650

Merged
blackmwk merged 7 commits into
apache:mainfrom
xanderbailey:xb/kms_factory
Jul 9, 2026
Merged

feat(encryption): [11/N] KMS factory trait and catalog wiring#2650
blackmwk merged 7 commits into
apache:mainfrom
xanderbailey:xb/kms_factory

Conversation

@xanderbailey

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

What changes are included in this PR?

Adds KmsClientFactory to the catalog builder to allow a catalog to build one KMS client per catalog, this mirrors Java's implementation . Note in rust we don't have reflection so using Java's encryption.kms-impl isn't an option for us. This is a very similar pattern to what we do for the StorageFactory so I believe the pattern is idiomatic for us.

Are these changes tested?

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @xanderbailey! This reads well. The factory mirrors StorageFactory, the client is created exactly once in load() with errors propagated, and it is forwarded uniformly to every TableBuilder across all six catalogs. The kms_client(impl Into<Option<...>>) forwarding is a nice ergonomic, and the docs are clear. A few non-blocking questions, mostly about how this connects to the read path and to Comet.

/// .load("my_catalog", props)
/// .await?;
/// ```
fn with_kms_client_factory(self, kms_client_factory: Arc<dyn KmsClientFactory>) -> Self;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As [11/N] of a stack, wiring the catalog KMS client to the Table's EncryptionManager is the whole job here. The read path can't consume it until a later part connects the EncryptionManager to file reads (the FileKeyResolver seam from your prototype), so there's nothing to change here. The one thing worth keeping in mind for that later PR: the seam should be feedable by either this KmsClientFactory-built client (native KMS) or an external resolver (a Comet-style JVM key source), since Comet resolves keys JVM-side and would not set a KmsClientFactory. Flagging it now only so the two entry points converge on one EncryptionManager/resolver seam rather than forking.

/// Called once during catalog initialization. Properties may include
/// KMS endpoint, region, credentials, or any backend-specific
/// configuration needed to construct the client.
async fn create_kms_client(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I first wondered if this should select the KMS per table, but the spec model is catalog-scoped KMS: per docs/docs/encryption.md the KMS is a catalog property (encryption.kms-type / encryption.kms-impl), and the per-table axis is the encryption.key-id master-key property, not a per-table KMS impl. So one client per catalog is right, and create_kms_client(&self.config.props) matches Java's KeyManagementClient.initialize(properties = catalog properties) exactly. No change on scope. The one thing to confirm is the other axis: that per-table encryption.key-id is honored when the EncryptionManager is built (the PR body implies it is). The recurring "key_metadata is implementation-specific" point (relevant to #2584/#2586, not this PR) is spec-backed too: format/spec.md field 131 and the encryption-keys section.

/// use std::sync::Arc;
///
/// let catalog = MyCatalogBuilder::default()
/// .with_kms_client_factory(Arc::new(MyKmsClientFactory))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Adding this as a required method breaks external CatalogBuilder implementors. A default impl that ignored the factory would silently drop encryption config, which is worse, so requiring it is probably the right call. Just worth calling out the breakage explicitly (changelog) so downstream implementors are not surprised.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah I suspect this is desired

master_key_size: AesKeySize,
}

impl MemoryKmsClientFactory {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

MemoryKmsClientFactory is the natural test seam, but I do not see a test that a factory-configured catalog yields a table whose EncryptionManager actually holds the client. A small test (build catalog with_kms_client_factory(MemoryKmsClientFactory), load a table, assert the client reached the EncryptionManager) would lock the wiring in. Good fit for the loader test suite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nice idea to have an end-to-end test for the in-memory catalog / kms. Added end-to-end test

@xanderbailey xanderbailey requested a review from mbutrovich July 7, 2026 22:10
@xanderbailey

Copy link
Copy Markdown
Contributor Author

@blackmwk I think this is ready for review when you have a moment! Thanks in advance!

@blackmwk blackmwk left a comment

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.

Thanks @xanderbailey for this pr, just finised first round of review.

Comment thread crates/iceberg/src/encryption/kms/memory.rs Outdated
Comment thread crates/iceberg/src/table.rs Outdated
/// Accepts either an `Arc<dyn KeyManagementClient>` or an
/// `Option<Arc<dyn KeyManagementClient>>`, so catalogs that hold an
/// optional client can forward it directly. Passing `None` is a no-op.
pub fn kms_client(

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 change is odd to me, we could just skip this method call if it's None.

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.

Also it makes the api inconsistent with other parts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I did it like this because else in all call sites you'd need:

  if let Some(c) = &self.kms_client {
      builder = builder.kms_client(c.clone());
  }

which doesn't read very nicely in my opinion.

Would just Option<...> be better rather than the Into?

Seems like we get away with this for other fields because they're actually always present when we use this builder but KMS client is legitimately optional.

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.

It sound reasonable to me. From a user's point, I accept both apis. But from a maintainer's point, I prefer the api without Option, since it's easier to maintain api consistency, otherwise we need to remember that for optional fields, we should accept Option, and other fields we don't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorry maybe I didn't quite follow, what's the recommendation here? You're happy with this API or should we change? To me it doesn't seem crazy for a genuinely optional parameter to have an optional builder.

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.

I mean I'm still in favor of removing the Option, since other apis in this crate adopt this approach, e.g. if a parameter is optional, user just don't call the function to set it. It would be easier to ensure api consistency in this repo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

remove optional builder, let me know what you think.

@blackmwk blackmwk left a comment

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.

Thanks @xanderbailey for this pr!

@blackmwk blackmwk merged commit facecaf into apache:main Jul 9, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants