#![allow(clippy::derive_partial_eq_without_eq)]
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt::Display;
use std::fmt::Formatter;
use std::str::FromStr;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use apollo_compiler::schema::ExtendedType;
use buildstructor::Builder;
use displaydoc::Display;
use itertools::Itertools;
use jsonwebtoken::DecodingKey;
use jsonwebtoken::Validation;
use jsonwebtoken::decode;
use jsonwebtoken::jwk::JwkSet;
use once_cell::sync::OnceCell;
use regex::Regex;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Visitor;
use serde_json::Value;
use strum::EnumIter;
use strum::IntoEnumIterator;
use thiserror::Error;
use super::parsed_link_spec::ParsedLinkSpec;
use crate::Configuration;
use crate::plugins::authentication::jwks::convert_key_algorithm;
use crate::spec::LINK_DIRECTIVE_NAME;
use crate::spec::Schema;
pub(crate) const LICENSE_EXPIRED_URL: &str = "https://go.apollo.dev/o/elp";
pub(crate) const LICENSE_EXPIRED_SHORT_MESSAGE: &str =
"Apollo license expired https://go.apollo.dev/o/elp";
pub(crate) const APOLLO_ROUTER_LICENSE_EXPIRED: &str = "APOLLO_ROUTER_LICENSE_EXPIRED";
static JWKS: OnceCell<JwkSet> = OnceCell::new();
#[derive(Error, Display, Debug)]
pub enum Error {
InvalidLicense(jsonwebtoken::errors::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum Audience {
SelfHosted,
Cloud,
Offline,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(untagged)]
pub(crate) enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub(crate) struct Claims {
pub(crate) iss: String,
pub(crate) sub: String,
pub(crate) aud: OneOrMany<Audience>,
#[serde(deserialize_with = "deserialize_epoch_seconds", rename = "warnAt")]
pub(crate) warn_at: SystemTime,
#[serde(deserialize_with = "deserialize_epoch_seconds", rename = "haltAt")]
pub(crate) halt_at: SystemTime,
#[serde(rename = "throughputLimit")]
pub(crate) tps: Option<TpsLimit>,
#[serde(rename = "allowedFeatures")]
pub(crate) allowed_features: Option<Vec<AllowedFeature>>,
}
fn deserialize_epoch_seconds<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
where
D: Deserializer<'de>,
{
let seconds = i32::deserialize(deserializer)?;
Ok(UNIX_EPOCH + Duration::from_secs(seconds as u64))
}
fn deserialize_ms_into_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let seconds = i32::deserialize(deserializer)?;
Ok(Duration::from_millis(seconds as u64))
}
#[derive(Debug)]
pub(crate) struct LicenseEnforcementReport {
restricted_config_in_use: Vec<ConfigurationRestriction>,
restricted_schema_in_use: Vec<SchemaViolation>,
}
impl LicenseEnforcementReport {
pub(crate) fn uses_restricted_features(&self) -> bool {
!self.restricted_config_in_use.is_empty() || !self.restricted_schema_in_use.is_empty()
}
pub(crate) fn build(
configuration: &Configuration,
schema: &Schema,
license: &LicenseState,
) -> LicenseEnforcementReport {
LicenseEnforcementReport {
restricted_config_in_use: Self::validate_configuration(
configuration,
&Self::configuration_restrictions(license),
),
restricted_schema_in_use: Self::validate_schema(
schema,
&Self::schema_restrictions(license),
),
}
}
pub(crate) fn restricted_features_in_use(&self) -> Vec<String> {
let mut restricted_features_in_use = Vec::new();
for restricted_config_in_use in self.restricted_config_in_use.clone() {
restricted_features_in_use.push(restricted_config_in_use.name.clone());
}
for restricted_schema_in_use in self.restricted_schema_in_use.clone() {
match restricted_schema_in_use {
SchemaViolation::Spec { name, .. } => {
restricted_features_in_use.push(name.clone());
}
SchemaViolation::DirectiveArgument { name, .. } => {
restricted_features_in_use.push(name.clone());
}
}
}
restricted_features_in_use
}
fn validate_configuration(
configuration: &Configuration,
configuration_restrictions: &Vec<ConfigurationRestriction>,
) -> Vec<ConfigurationRestriction> {
let mut selector = jsonpath_lib::selector(
configuration
.validated_yaml
.as_ref()
.unwrap_or(&Value::Null),
);
let mut configuration_violations = Vec::new();
for restriction in configuration_restrictions {
if let Some(value) = selector(&restriction.path)
.expect("path on restriction was not valid")
.first()
{
if let Some(restriction_value) = &restriction.value {
if *value == restriction_value {
configuration_violations.push(restriction.clone());
}
} else {
configuration_violations.push(restriction.clone());
}
}
}
configuration_violations
}
fn validate_schema(
schema: &Schema,
schema_restrictions: &Vec<SchemaRestriction>,
) -> Vec<SchemaViolation> {
let link_specs = schema
.supergraph_schema()
.schema_definition
.directives
.get_all(LINK_DIRECTIVE_NAME)
.filter_map(|link| {
ParsedLinkSpec::from_link_directive(link).map(|maybe_spec| {
maybe_spec.ok().map(|spec| (spec.spec_url.to_owned(), spec))
})?
})
.collect::<HashMap<_, _>>();
let link_specs_in_join_directive = schema
.supergraph_schema()
.schema_definition
.directives
.get_all("join__directive")
.filter(|join| {
join.specified_argument_by_name("name")
.and_then(|name| name.as_str())
.map(|name| name == LINK_DIRECTIVE_NAME)
.unwrap_or_default()
})
.filter_map(|join| {
join.specified_argument_by_name("args")
.and_then(|arg| arg.as_object())
})
.filter_map(|link| {
ParsedLinkSpec::from_join_directive_args(link).map(|maybe_spec| {
maybe_spec.ok().map(|spec| (spec.spec_url.to_owned(), spec))
})?
})
.collect::<HashMap<_, _>>();
let mut schema_violations: Vec<SchemaViolation> = Vec::new();
for restriction in schema_restrictions {
match restriction {
SchemaRestriction::Spec {
spec_url,
name,
version_req,
} => {
if let Some(link_spec) = link_specs.get(spec_url)
&& version_req.matches(&link_spec.version)
{
schema_violations.push(SchemaViolation::Spec {
url: link_spec.url.to_string(),
name: name.to_string(),
});
}
}
SchemaRestriction::DirectiveArgument {
spec_url,
name,
version_req,
argument,
explanation,
} => {
if let Some(link_spec) = link_specs.get(spec_url)
&& version_req.matches(&link_spec.version)
{
let directive_name = link_spec.directive_name(name);
if schema
.supergraph_schema()
.types
.values()
.flat_map(|def| match def {
ExtendedType::Object(object_type_def) => {
let directives_on_object = object_type_def
.directives
.get_all(&directive_name)
.map(|component| &component.node);
let directives_on_fields =
object_type_def.fields.values().flat_map(|field| {
field.directives.get_all(&directive_name)
});
directives_on_object
.chain(directives_on_fields)
.collect::<Vec<_>>()
}
_ => vec![],
})
.any(|directive| {
directive.specified_argument_by_name(argument).is_some()
})
{
schema_violations.push(SchemaViolation::DirectiveArgument {
url: link_spec.url.to_string(),
name: directive_name.to_string(),
argument: argument.to_string(),
explanation: explanation.to_string(),
});
}
}
}
SchemaRestriction::SpecInJoinDirective {
spec_url,
name,
version_req,
} => {
if let Some(link_spec) = link_specs_in_join_directive.get(spec_url)
&& version_req.matches(&link_spec.version)
{
schema_violations.push(SchemaViolation::Spec {
url: link_spec.url.to_string(),
name: name.to_string(),
});
}
}
}
}
schema_violations
}
fn configuration_restrictions(license: &LicenseState) -> Vec<ConfigurationRestriction> {
let mut configuration_restrictions = vec![];
let allowed_features = license.get_allowed_features();
if !allowed_features.contains(&AllowedFeature::ApqCaching) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.apq.router.cache.redis")
.name("APQ caching")
.build(),
)
}
if !allowed_features.contains(&AllowedFeature::Authentication) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.authentication.router")
.name("Authentication plugin")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::Authorization) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.authorization.directives")
.name("Authorization directives")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::Batching) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.batching")
.name("Batching support")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::EntityCaching) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.preview_entity_cache.enabled")
.value(true)
.name("Subgraph entity caching")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::ResponseCaching) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.response_cache.enabled")
.value(true)
.name("Subgraph response caching")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::PersistedQueries) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.persisted_queries")
.name("Persisted queries")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::Subscriptions) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.subscription.enabled")
.value(true)
.name("Federated subscriptions")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::Coprocessors) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.coprocessor")
.name("Coprocessor plugin")
.build(),
)
}
if !allowed_features.contains(&AllowedFeature::DistributedQueryPlanning) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.supergraph.query_planning.cache.redis")
.name("Query plan caching")
.build(),
)
}
if !allowed_features.contains(&AllowedFeature::DemandControl) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.demand_control")
.name("Demand control plugin")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::Experimental) {
configuration_restrictions.push(
ConfigurationRestriction::builder()
.path("$.plugins.['experimental.restricted'].enabled")
.value(true)
.name("Restricted")
.build(),
);
}
if !allowed_features.contains(&AllowedFeature::RequestLimits) {
configuration_restrictions.extend(vec![
ConfigurationRestriction::builder()
.path("$.limits.max_depth")
.name("Operation depth limiting")
.build(),
ConfigurationRestriction::builder()
.path("$.limits.max_height")
.name("Operation height limiting")
.build(),
ConfigurationRestriction::builder()
.path("$.limits.max_root_fields")
.name("Operation root fields limiting")
.build(),
ConfigurationRestriction::builder()
.path("$.limits.max_aliases")
.name("Operation aliases limiting")
.build(),
]);
}
configuration_restrictions
}
fn schema_restrictions(license: &LicenseState) -> Vec<SchemaRestriction> {
let mut schema_restrictions = vec![];
let allowed_features = license.get_allowed_features();
if !allowed_features.contains(&AllowedFeature::Authentication) {
schema_restrictions.push(SchemaRestriction::Spec {
name: "authenticated".to_string(),
spec_url: "https://specs.apollo.dev/authenticated".to_string(),
version_req: semver::VersionReq {
comparators: vec![semver::Comparator {
op: semver::Op::Exact,
major: 0,
minor: 1.into(),
patch: 0.into(),
pre: semver::Prerelease::EMPTY,
}],
},
});
schema_restrictions.push(SchemaRestriction::Spec {
name: "requiresScopes".to_string(),
spec_url: "https://specs.apollo.dev/requiresScopes".to_string(),
version_req: semver::VersionReq {
comparators: vec![semver::Comparator {
op: semver::Op::Exact,
major: 0,
minor: 1.into(),
patch: 0.into(),
pre: semver::Prerelease::EMPTY,
}],
},
});
}
schema_restrictions
}
}
impl Display for LicenseEnforcementReport {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if !self.restricted_config_in_use.is_empty() {
let restricted_config = self
.restricted_config_in_use
.iter()
.map(|v| format!("* {}\n {}", v.name, v.path.replace("$.", ".")))
.join("\n\n");
write!(f, "Configuration yaml:\n{restricted_config}")?;
if !self.restricted_schema_in_use.is_empty() {
writeln!(f)?;
}
}
if !self.restricted_schema_in_use.is_empty() {
let restricted_schema = self
.restricted_schema_in_use
.iter()
.map(|v| v.to_string())
.join("\n\n");
write!(f, "Schema features:\n{restricted_schema}")?
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct License {
pub(crate) claims: Option<Claims>,
}
#[derive(Builder, Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) struct TpsLimit {
pub(crate) capacity: usize,
#[serde(
deserialize_with = "deserialize_ms_into_duration",
rename = "durationMs"
)]
pub(crate) interval: Duration,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Hash, EnumIter)]
#[serde(rename_all = "snake_case")]
pub enum AllowedFeature {
Apq,
ApqCaching,
Authentication,
Authorization,
Batching,
Coprocessors,
DemandControl,
DistributedQueryPlanning,
EntityCaching,
ResponseCaching,
Experimental,
ExtendedReferenceReporting,
PersistedQueries,
RequestLimits,
Subscriptions,
TrafficShaping,
Other(String),
}
impl From<&str> for AllowedFeature {
fn from(feature: &str) -> Self {
match feature {
"apq" => Self::Apq,
"apq_caching" => Self::ApqCaching,
"authentication" => Self::Authentication,
"authorization" => Self::Authorization,
"batching" => Self::Batching,
"coprocessors" => Self::Coprocessors,
"demand_control" => Self::DemandControl,
"distributed_query_planning" => Self::DistributedQueryPlanning,
"entity_caching" => Self::EntityCaching,
"response_caching" => Self::ResponseCaching,
"experimental" => Self::Experimental,
"extended_reference_reporting" => Self::ExtendedReferenceReporting,
"persisted_queries" => Self::PersistedQueries,
"request_limits" => Self::RequestLimits,
"subscriptions" => Self::Subscriptions,
"traffic_shaping" => Self::TrafficShaping,
other => Self::Other(other.into()),
}
}
}
impl AllowedFeature {
pub fn from_plugin_name(plugin_name: &str) -> Option<AllowedFeature> {
match plugin_name {
"traffic_shaping" => Some(AllowedFeature::TrafficShaping),
"limits" => Some(AllowedFeature::RequestLimits),
"subscription" => Some(AllowedFeature::Subscriptions),
"authorization" => Some(AllowedFeature::Authorization),
"authentication" => Some(AllowedFeature::Authentication),
"preview_entity_cache" => Some(AllowedFeature::EntityCaching),
"response_cache" => Some(AllowedFeature::ResponseCaching),
"demand_control" => Some(AllowedFeature::DemandControl),
"coprocessor" => Some(AllowedFeature::Coprocessors),
_other => None,
}
}
}
impl<'de> Deserialize<'de> for AllowedFeature {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct AllowedFeatureVisitor;
impl<'de> Visitor<'de> for AllowedFeatureVisitor {
type Value = AllowedFeature;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("a string representing an allowed feature")
}
fn visit_str<E>(self, value: &str) -> Result<AllowedFeature, E>
where
E: serde::de::Error,
{
Ok(AllowedFeature::from(value))
}
}
deserializer.deserialize_str(AllowedFeatureVisitor)
}
}
#[derive(Debug, Builder, Clone, Eq, PartialEq)]
pub struct LicenseLimits {
pub(crate) tps: Option<TpsLimit>,
pub(crate) allowed_features: HashSet<AllowedFeature>,
}
impl Default for LicenseLimits {
fn default() -> Self {
Self {
tps: None,
allowed_features: HashSet::from_iter(AllowedFeature::iter()),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Default, Display)]
pub enum LicenseState {
Licensed { limits: Option<LicenseLimits> },
LicensedWarn { limits: Option<LicenseLimits> },
LicensedHalt { limits: Option<LicenseLimits> },
#[default]
Unlicensed,
}
impl LicenseState {
pub(crate) fn get_limits(&self) -> Option<&LicenseLimits> {
match self {
LicenseState::Licensed { limits }
| LicenseState::LicensedWarn { limits }
| LicenseState::LicensedHalt { limits } => limits.as_ref(),
_ => None,
}
}
pub(crate) fn get_allowed_features(&self) -> HashSet<AllowedFeature> {
match self {
LicenseState::Licensed { limits }
| LicenseState::LicensedWarn { limits }
| LicenseState::LicensedHalt { limits } => {
match limits {
Some(limits) => limits.allowed_features.clone(),
None => HashSet::from_iter(AllowedFeature::iter()),
}
}
LicenseState::Unlicensed => HashSet::new(),
}
}
pub(crate) fn get_name(&self) -> &'static str {
match self {
Self::Licensed { limits: _ } => "Licensed",
Self::LicensedWarn { limits: _ } => "LicensedWarn",
Self::LicensedHalt { limits: _ } => "LicensedHalt",
Self::Unlicensed => "Unlicensed",
}
}
}
impl Display for License {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if let Some(claims) = &self.claims {
write!(
f,
"{}",
serde_json::to_string(claims)
.unwrap_or_else(|_| "claim serialization error".to_string())
)
} else {
write!(f, "no license")
}
}
}
impl FromStr for License {
type Err = Error;
fn from_str(jwt: &str) -> Result<Self, Self::Err> {
Self::jwks()
.keys
.iter()
.map(|jwk| {
let mut validation = Validation::new(
convert_key_algorithm(
jwk.common
.key_algorithm
.expect("alg is required on all keys in router.jwks.json"),
)
.expect("only signing algorithms are used"),
);
validation.validate_exp = false;
validation.set_required_spec_claims(&["iss", "sub", "aud", "warnAt", "haltAt"]);
validation.set_issuer(&["https://www.apollographql.com/"]);
validation.set_audience(&["CLOUD", "SELF_HOSTED", "OFFLINE"]);
decode::<Claims>(
jwt.trim(),
&DecodingKey::from_jwk(jwk).expect("router.jwks.json must be valid"),
&validation,
)
.map_err(Error::InvalidLicense)
.map(|r| License {
claims: Some(r.claims),
})
})
.find_or_last(|r| r.is_ok())
.transpose()
.map(|e| {
let e = e.unwrap_or_default();
tracing::debug!("decoded license {jwt}->{e}");
e
})
}
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ConfigurationRestriction {
name: String,
path: String,
value: Option<Value>,
}
#[derive(Clone, Debug)]
pub(crate) enum SchemaRestriction {
Spec {
spec_url: String,
name: String,
version_req: semver::VersionReq,
},
#[allow(dead_code)]
DirectiveArgument {
spec_url: String,
name: String,
version_req: semver::VersionReq,
argument: String,
explanation: String,
},
#[allow(dead_code)]
SpecInJoinDirective {
spec_url: String,
name: String,
version_req: semver::VersionReq,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum SchemaViolation {
Spec {
url: String,
name: String,
},
DirectiveArgument {
url: String,
name: String,
argument: String,
explanation: String,
},
}
impl Display for SchemaViolation {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
SchemaViolation::Spec { name, url } => {
write!(f, "* @{name}\n {url}")
}
SchemaViolation::DirectiveArgument {
name,
url,
argument,
explanation,
} => {
write!(f, "* @{name}.{argument}\n {url}\n\n{explanation}")
}
}
}
}
impl License {
pub(crate) fn jwks() -> &'static JwkSet {
JWKS.get_or_init(|| {
let re = Regex::new("(?m)^//.*$").expect("regex must be valid");
let jwks = if let Ok(jwks_path) = std::env::var("APOLLO_TEST_INTERNAL_UPLINK_JWKS") {
tracing::debug!("using a dummy JWKS endpoint: {jwks_path:?}");
let jwks = std::fs::read_to_string(jwks_path)
.expect("dummy JWKS endpoint couldn't be read into memory");
re.replace(&jwks, "").into_owned()
} else {
re.replace(include_str!("license.jwks.json"), "")
.into_owned()
};
serde_json::from_str::<JwkSet>(&jwks).expect("router jwks must be valid")
})
}
}
#[cfg(test)]
mod test {
use std::collections::HashSet;
use std::str::FromStr;
use std::time::Duration;
use std::time::UNIX_EPOCH;
use insta::assert_snapshot;
use serde_json::json;
use crate::AllowedFeature;
use crate::Configuration;
use crate::spec::Schema;
use crate::uplink::license_enforcement::Audience;
use crate::uplink::license_enforcement::Claims;
use crate::uplink::license_enforcement::License;
use crate::uplink::license_enforcement::LicenseEnforcementReport;
use crate::uplink::license_enforcement::LicenseLimits;
use crate::uplink::license_enforcement::LicenseState;
use crate::uplink::license_enforcement::OneOrMany;
#[track_caller]
fn check(
router_yaml: &str,
supergraph_schema: &str,
license: LicenseState,
) -> LicenseEnforcementReport {
let config = Configuration::from_str(router_yaml).expect("router config must be valid");
let schema =
Schema::parse(supergraph_schema, &config).expect("supergraph schema must be valid");
LicenseEnforcementReport::build(&config, &schema, &license)
}
#[test]
fn test_oss() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/oss.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_config_in_use.is_empty(),
"should not have found restricted features"
);
}
#[test]
fn test_restricted_features_via_config_unlicensed() {
let report = check(
include_str!("testdata/restricted.router.yaml"),
include_str!("testdata/oss.graphql"),
LicenseState::default(),
);
assert!(
!report.restricted_config_in_use.is_empty(),
"should have found restricted features"
);
assert_snapshot!(report.to_string());
}
#[test]
fn test_restricted_features_via_config_allowed_features_empty() {
let report = check(
include_str!("testdata/restricted.router.yaml"),
include_str!("testdata/oss.graphql"),
LicenseState::Licensed {
limits: Some(LicenseLimits {
tps: None,
allowed_features: HashSet::from_iter(vec![]),
}),
},
);
assert!(
!report.restricted_config_in_use.is_empty(),
"should have found restricted features"
);
assert_snapshot!(report.to_string());
}
#[test]
fn test_restricted_features_via_config_with_allowed_features() {
let report = check(
include_str!("testdata/restricted.router.yaml"),
include_str!("testdata/oss.graphql"),
LicenseState::Licensed {
limits: Some(LicenseLimits {
tps: None,
allowed_features: HashSet::from_iter(vec![
AllowedFeature::Authentication,
AllowedFeature::Authorization,
AllowedFeature::Batching,
AllowedFeature::DemandControl,
AllowedFeature::EntityCaching,
AllowedFeature::PersistedQueries,
AllowedFeature::ApqCaching,
]),
}),
},
);
assert!(
!report.restricted_config_in_use.is_empty(),
"should have found restricted features"
);
assert_snapshot!(report.to_string());
}
#[test]
fn test_restricted_authorization_directives_via_schema_unlicensed() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/authorization.graphql"),
LicenseState::default(),
);
assert!(
!report.restricted_schema_in_use.is_empty(),
"should have found restricted features"
);
assert_snapshot!(report.to_string());
}
#[test]
fn test_restricted_authorization_directives_via_schema_with_restricted_allowed_features() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/authorization.graphql"),
LicenseState::Licensed {
limits: Some(LicenseLimits {
tps: None,
allowed_features: HashSet::from_iter(vec![
AllowedFeature::Authentication,
AllowedFeature::Authorization,
]),
}),
},
);
assert!(
report.restricted_schema_in_use.is_empty(),
"should have not found restricted features"
);
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/authorization.graphql"),
LicenseState::Licensed {
limits: Some(LicenseLimits {
tps: None,
allowed_features: HashSet::from_iter(vec![AllowedFeature::DemandControl]),
}),
},
);
assert!(
!report.restricted_schema_in_use.is_empty(),
"should have found restricted features"
);
assert_snapshot!(report.to_string());
}
#[test]
fn test_restricted_authorization_directives_via_schema_with_default_license_limits() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/authorization.graphql"),
LicenseState::Licensed {
limits: Default::default(),
},
);
assert!(
report.restricted_schema_in_use.is_empty(),
"should have not found restricted features"
);
}
#[test]
#[cfg(not(windows))] fn unix_socket_available_to_oss() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/unix_socket.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn schema_enforcement_allows_context_directive_for_oss() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/set_context.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
#[cfg(not(windows))] fn test_restricted_unix_socket_via_schema_when_allowed_features_empty() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/unix_socket.graphql"),
LicenseState::Licensed {
limits: Some(LicenseLimits {
tps: None,
allowed_features: HashSet::new(),
}),
},
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn test_license_parse() {
let license = License::from_str("eyJhbGciOiJFZERTQSJ9.eyJpc3MiOiJodHRwczovL3d3dy5hcG9sbG9ncmFwaHFsLmNvbS8iLCJzdWIiOiJhcG9sbG8iLCJhdWQiOiJTRUxGX0hPU1RFRCIsIndhcm5BdCI6MTY3NjgwODAwMCwiaGFsdEF0IjoxNjc4MDE3NjAwfQ.tXexfjZ2SQeqSwkWQ7zD4XBoxS_Hc5x7tSNJ3ln-BCL_GH7i3U9hsIgdRQTczCAjA_jjk34w39DeSV0nTc5WBw").expect("must be able to decode JWT");
assert_eq!(
license.claims,
Some(Claims {
iss: "https://www.apollographql.com/".to_string(),
sub: "apollo".to_string(),
aud: OneOrMany::One(Audience::SelfHosted),
warn_at: UNIX_EPOCH + Duration::from_secs(1676808000),
halt_at: UNIX_EPOCH + Duration::from_secs(1678017600),
tps: Default::default(),
allowed_features: Default::default()
}),
);
}
#[test]
fn test_license_parse_with_whitespace() {
let license = License::from_str(" eyJhbGciOiJFZERTQSJ9.eyJpc3MiOiJodHRwczovL3d3dy5hcG9sbG9ncmFwaHFsLmNvbS8iLCJzdWIiOiJhcG9sbG8iLCJhdWQiOiJTRUxGX0hPU1RFRCIsIndhcm5BdCI6MTY3NjgwODAwMCwiaGFsdEF0IjoxNjc4MDE3NjAwfQ.tXexfjZ2SQeqSwkWQ7zD4XBoxS_Hc5x7tSNJ3ln-BCL_GH7i3U9hsIgdRQTczCAjA_jjk34w39DeSV0nTc5WBw\n ").expect("must be able to decode JWT"); assert_eq!(
license.claims,
Some(Claims {
iss: "https://www.apollographql.com/".to_string(),
sub: "apollo".to_string(),
aud: OneOrMany::One(Audience::SelfHosted),
warn_at: UNIX_EPOCH + Duration::from_secs(1676808000),
halt_at: UNIX_EPOCH + Duration::from_secs(1678017600),
tps: Default::default(),
allowed_features: Default::default()
}),
);
}
#[test]
fn test_license_parse_fail() {
License::from_str("invalid").expect_err("jwt must fail parse");
}
#[test]
fn claims_serde() {
serde_json::from_value::<Claims>(json!({
"iss": "Issuer",
"sub": "Subject",
"aud": "CLOUD",
"warnAt": 122,
"haltAt": 123,
}))
.expect("json must deserialize");
serde_json::from_value::<Claims>(json!({
"iss": "Issuer",
"sub": "Subject",
"aud": ["CLOUD", "SELF_HOSTED"],
"warnAt": 122,
"haltAt": 123,
}))
.expect("json must deserialize");
serde_json::from_value::<Claims>(json!({
"iss": "Issuer",
"sub": "Subject",
"aud": "OFFLINE",
"warnAt": 122,
"haltAt": 123,
}))
.expect("json must deserialize");
serde_json::from_value::<Claims>(json!({
"iss": "Issuer",
"sub": "Subject",
"aud": "OFFLINE",
"warnAt": 122,
"haltAt": 123,
"allowedFeatures": ["SUBSCRIPTIONS", "ENTITY_CACHING"]
}))
.expect("json must deserialize");
}
#[test]
fn progressive_override_available_to_oss() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/progressive_override.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn set_context() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/set_context.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn progressive_override_with_renamed_join_spec() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/progressive_override_renamed_join.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn schema_enforcement_spec_version_in_range() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/schema_enforcement_spec_version_in_range.graphql"),
LicenseState::default(),
);
assert!(
!report.restricted_schema_in_use.is_empty(),
"should have found restricted features"
);
assert_snapshot!(report.to_string());
}
#[test]
fn schema_enforcement_spec_version_out_of_range() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/schema_enforcement_spec_version_out_of_range.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn schema_enforcement_directive_arg_version_in_range() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/schema_enforcement_directive_arg_version_in_range.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn schema_enforcement_directive_arg_version_out_of_range() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/schema_enforcement_directive_arg_version_out_of_range.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted features"
);
}
#[test]
fn schema_enforcement_connectors() {
let report = check(
include_str!("testdata/oss.router.yaml"),
include_str!("testdata/schema_enforcement_connectors.graphql"),
LicenseState::default(),
);
assert!(
report.restricted_schema_in_use.is_empty(),
"shouldn't have found restricted connect feature"
);
}
}