Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/license-keys/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ uuid = { workspace = true, optional = true, features = ["v4"] }
[dev-dependencies]
clap.workspace = true
mz-aws-util = { path = "../aws-util" }
mz-ore = { path = "../ore", default-features = false, features = ["test"] }
serde_json.workspace = true
tokio.workspace = true

[features]
Expand Down
5 changes: 5 additions & 0 deletions src/license-keys/examples/sign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ struct Opt {
max_credit_consumption_rate: f64,
#[clap(long, default_value_t = 365 * 24 * 60 * 60)]
validity_secs: u64,
/// Repeatable: each `--entitlement <name>` adds one entitlement to the
/// signed key (e.g. `--entitlement ory`).
#[clap(long = "entitlement")]
entitlements: Vec<String>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not positive if Vec is the right shape.
Abrief discussion:

We do have https://github.com/MaterializeInc/materialize/pull/32317/changes#diff-c01e42909e80f54d64977d77b5db88d93959e5e91454a4fcd8f650409848c3d4R144

I think this makes sense if we roughly think of these as "FeatureFlags" which are always bool, and we treat inclusion as true, and omission as take the default. If we wanted to adopt broader systemVar it could be done in a seperate field.

Copy link
Copy Markdown
Contributor Author

@alex-hunt-materialize alex-hunt-materialize May 11, 2026

Choose a reason for hiding this comment

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

That seems very complicated. We also don't want to lock to a specific version of MZ, and we don't have access to this code from the oci-registry-proxy repo.

I see the work entitlement as "something you are allowed to get". To me, this is a simple boolean, and omission is false (or default I guess, but if you aren't "entitled" to it, the default is probably false).

Do you have a more concrete example of what type you want here? I don't really want to be juggling serde_json::Value everywhere.

}

#[tokio::main]
Expand All @@ -53,6 +57,7 @@ async fn main() {
opt.max_credit_consumption_rate,
false,
ExpirationBehavior::Warn,
opt.entitlements,
)
.await
.unwrap();
Expand Down
85 changes: 85 additions & 0 deletions src/license-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ pub struct ValidatedLicenseKey {
pub allow_credit_consumption_override: bool,
pub expiration_behavior: ExpirationBehavior,
pub expired: bool,
/// Optional feature flags / third-party integrations enabled for this key
/// (e.g. `"ory"` to permit pulling images through the OCI registry proxy).
/// Empty for keys that predate this field.
pub entitlements: Vec<String>,
}

impl ValidatedLicenseKey {
Expand All @@ -59,6 +63,7 @@ impl ValidatedLicenseKey {
allow_credit_consumption_override: true,
expiration_behavior: ExpirationBehavior::Warn,
expired: false,
entitlements: Vec::new(),
}
}

Expand All @@ -74,9 +79,15 @@ impl ValidatedLicenseKey {
allow_credit_consumption_override: true,
expiration_behavior: ExpirationBehavior::Warn,
expired: false,
entitlements: Vec::new(),
}
}

/// Returns true if `entitlement` is present in this key.
pub fn has_entitlement(&self, entitlement: &str) -> bool {
self.entitlements.iter().any(|e| e == entitlement)
}

pub fn max_credit_consumption_rate(&self) -> Option<f64> {
if self.expired
&& matches!(
Expand Down Expand Up @@ -106,6 +117,7 @@ impl Default for ValidatedLicenseKey {
allow_credit_consumption_override: false,
expiration_behavior: ExpirationBehavior::Disable,
expired: false,
entitlements: Vec::new(),
}
}
}
Expand Down Expand Up @@ -176,6 +188,11 @@ struct Payload {
#[serde(default, skip_serializing_if = "is_default")]
allow_credit_consumption_override: bool,
expiration_behavior: ExpirationBehavior,
// Defaulted + skipped-when-empty so keys issued before entitlements
// existed continue to validate and we don't bloat keys that don't need
// any entitlements.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entitlements: Vec<String>,
}

fn validate_with_pubkey_v1(
Expand Down Expand Up @@ -231,9 +248,77 @@ fn validate_with_pubkey_v1(
allow_credit_consumption_override: jwt.claims.allow_credit_consumption_override,
expiration_behavior: jwt.claims.expiration_behavior,
expired,
entitlements: jwt.claims.entitlements,
})
}

fn is_default<T: PartialEq + Eq + Default>(val: &T) -> bool {
*val == T::default()
}

#[cfg(test)]
mod tests {
use super::*;

fn sample_payload(entitlements: Vec<String>) -> Payload {
Payload {
sub: "org-1".to_string(),
exp: 200,
nbf: 100,
iss: ISSUER.to_string(),
aud: "env-1".to_string(),
iat: 100,
jti: "jti-1".to_string(),
version: 1,
max_credit_consumption_rate: 10.0,
allow_credit_consumption_override: false,
expiration_behavior: ExpirationBehavior::Warn,
entitlements,
}
}

#[mz_ore::test]
fn entitlements_roundtrip_through_payload() {
let payload = sample_payload(vec!["ory".to_string(), "foo".to_string()]);
let json = serde_json::to_string(&payload).unwrap();
assert!(
json.contains(r#""entitlements":["ory","foo"]"#),
"payload should serialize entitlements: {json}"
);

let decoded: Payload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.entitlements, vec!["ory", "foo"]);
}

#[mz_ore::test]
fn empty_entitlements_omitted_from_payload() {
let payload = sample_payload(Vec::new());
let json = serde_json::to_string(&payload).unwrap();
// Skipping the field on empty keeps issued JWTs the same shape they
// were before entitlements existed, so old + new validators agree.
assert!(
!json.contains("entitlements"),
"empty entitlements should be skipped: {json}"
);
}

#[mz_ore::test]
fn legacy_payload_without_entitlements_decodes() {
// Pre-DEP-130 keys have no `entitlements` field. They must still
// deserialize, with entitlements defaulting to empty.
let legacy = serde_json::json!({
"sub": "org-1",
"exp": 200,
"nbf": 100,
"iss": ISSUER,
"aud": "env-1",
"iat": 100,
"jti": "jti-1",
"version": 1,
"max_credit_consumption_rate": 10.0,
"expiration_behavior": "Warn",
});
let decoded: Payload = serde_json::from_value(legacy).unwrap();
assert!(decoded.entitlements.is_empty());
}
}
3 changes: 3 additions & 0 deletions src/license-keys/src/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub async fn get_pubkey_pem(client: &aws_sdk_kms::Client, key_id: &str) -> anyho
Ok(pem.to_string())
}

#[allow(clippy::too_many_arguments)]
pub async fn make_license_key(
client: &aws_sdk_kms::Client,
key_id: &str,
Expand All @@ -39,6 +40,7 @@ pub async fn make_license_key(
max_credit_consumption_rate: f64,
allow_credit_consumption_override: bool,
expiration_behavior: ExpirationBehavior,
entitlements: Vec<String>,
) -> anyhow::Result<String> {
let mut headers = Header::new(Algorithm::PS256);
headers.typ = Some("JWT".to_string());
Expand All @@ -58,6 +60,7 @@ pub async fn make_license_key(
max_credit_consumption_rate,
allow_credit_consumption_override,
expiration_behavior,
entitlements,
};
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes());

Expand Down
Loading