From 5d49df7d9c69677f208f9364ade786131887f8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Tue, 31 Mar 2026 07:45:10 +0200 Subject: [PATCH 01/11] feat: add FlightSql and Federation features with corresponding tests and update quotas Closed Issue #62 --- licensing/build.sbt | 2 + .../licensing/DefaultLicenseManager.scala | 8 +- .../elastic/licensing/package.scala | 18 ++- .../elastic/licensing/FeatureSpec.scala | 52 +++++++ .../elastic/licensing/LicenseKeySpec.scala | 87 ++++++++++++ .../licensing/LicenseManagerSpec.scala | 131 ++++++++++++++++++ .../elastic/licensing/QuotaSpec.scala | 80 +++++++++++ 7 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureSpec.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala diff --git a/licensing/build.sbt b/licensing/build.sbt index 59ed8497..e73e8a70 100644 --- a/licensing/build.sbt +++ b/licensing/build.sbt @@ -2,3 +2,5 @@ organization := "app.softnetwork.elastic" name := "softclient4es-licensing" +libraryDependencies += "org.scalatest" %% "scalatest" % Versions.scalatest % Test + diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala index 348086ab..f62cfe59 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala @@ -37,7 +37,8 @@ class DefaultLicenseManager extends LicenseManager { features = Set( Feature.MaterializedViews, Feature.JdbcDriver, - Feature.UnlimitedResults + Feature.UnlimitedResults, + Feature.FlightSql ), expiresAt = None ) @@ -52,7 +53,10 @@ class DefaultLicenseManager extends LicenseManager { Feature.MaterializedViews, Feature.JdbcDriver, Feature.OdbcDriver, - Feature.UnlimitedResults + Feature.UnlimitedResults, + Feature.AdvancedAggregations, + Feature.FlightSql, + Feature.Federation ), expiresAt = None ) diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index e67c2caa..bec47384 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -43,12 +43,16 @@ package object licensing { case object OdbcDriver extends Feature case object UnlimitedResults extends Feature case object AdvancedAggregations extends Feature + case object FlightSql extends Feature + case object Federation extends Feature def values: Seq[Feature] = Seq( MaterializedViews, JdbcDriver, OdbcDriver, UnlimitedResults, - AdvancedAggregations + AdvancedAggregations, + FlightSql, + Federation ) } @@ -63,26 +67,30 @@ package object licensing { case class Quota( maxMaterializedViews: Option[Int], // None = unlimited maxQueryResults: Option[Int], // None = unlimited - maxConcurrentQueries: Option[Int] + maxConcurrentQueries: Option[Int], + maxClusters: Option[Int] = Some(2) // None = unlimited ) object Quota { val Community: Quota = Quota( maxMaterializedViews = Some(3), maxQueryResults = Some(10000), - maxConcurrentQueries = Some(5) + maxConcurrentQueries = Some(5), + maxClusters = Some(2) ) val Pro: Quota = Quota( maxMaterializedViews = Some(50), maxQueryResults = Some(1000000), - maxConcurrentQueries = Some(50) + maxConcurrentQueries = Some(50), + maxClusters = Some(5) ) val Enterprise: Quota = Quota( maxMaterializedViews = None, // Unlimited maxQueryResults = None, - maxConcurrentQueries = None + maxConcurrentQueries = None, + maxClusters = None ) } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureSpec.scala new file mode 100644 index 00000000..c5104296 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureSpec.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class FeatureSpec extends AnyFlatSpec with Matchers { + + "Feature.FlightSql" should "exist as a case object" in { + Feature.FlightSql shouldBe a[Feature] + } + + "Feature.Federation" should "exist as a case object" in { + Feature.Federation shouldBe a[Feature] + } + + "Feature.values" should "contain all 7 features" in { + Feature.values should have size 7 + } + + it should "contain FlightSql and Federation" in { + Feature.values should contain(Feature.FlightSql) + Feature.values should contain(Feature.Federation) + } + + it should "preserve insertion order" in { + Feature.values shouldBe Seq( + Feature.MaterializedViews, + Feature.JdbcDriver, + Feature.OdbcDriver, + Feature.UnlimitedResults, + Feature.AdvancedAggregations, + Feature.FlightSql, + Feature.Federation + ) + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala new file mode 100644 index 00000000..e5527319 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseKeySpec extends AnyFlatSpec with Matchers { + + "LicenseKey" should "support FlightSql in features set" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Pro, + features = Set(Feature.FlightSql), + expiresAt = None + ) + key.features should contain(Feature.FlightSql) + } + + it should "support Federation in features set" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Enterprise, + features = Set(Feature.Federation), + expiresAt = None + ) + key.features should contain(Feature.Federation) + } + + it should "support all features combined" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Enterprise, + features = Feature.values.toSet, + expiresAt = None + ) + key.features should have size 7 + key.features should contain(Feature.MaterializedViews) + key.features should contain(Feature.JdbcDriver) + key.features should contain(Feature.OdbcDriver) + key.features should contain(Feature.UnlimitedResults) + key.features should contain(Feature.AdvancedAggregations) + key.features should contain(Feature.FlightSql) + key.features should contain(Feature.Federation) + } + + it should "store JWT metadata claims" in { + val key = LicenseKey( + id = "jwt-key", + licenseType = LicenseType.Pro, + features = Set(Feature.FlightSql), + expiresAt = None, + metadata = Map( + "org_name" -> "Acme Corp", + "jti" -> "abc-123", + "trial" -> "true" + ) + ) + key.metadata("org_name") shouldBe "Acme Corp" + key.metadata("jti") shouldBe "abc-123" + key.metadata("trial") shouldBe "true" + } + + it should "default to empty metadata" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Community, + features = Set.empty, + expiresAt = None + ) + key.metadata shouldBe empty + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala new file mode 100644 index 00000000..fd8e6c02 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseManagerSpec extends AnyFlatSpec with Matchers { + + "DefaultLicenseManager with Community license" should "include MaterializedViews" in { + val manager = new DefaultLicenseManager + manager.hasFeature(Feature.MaterializedViews) shouldBe true + } + + it should "include JdbcDriver" in { + val manager = new DefaultLicenseManager + manager.hasFeature(Feature.JdbcDriver) shouldBe true + } + + it should "not include FlightSql" in { + val manager = new DefaultLicenseManager + manager.hasFeature(Feature.FlightSql) shouldBe false + } + + it should "not include Federation" in { + val manager = new DefaultLicenseManager + manager.hasFeature(Feature.Federation) shouldBe false + } + + it should "not include OdbcDriver" in { + val manager = new DefaultLicenseManager + manager.hasFeature(Feature.OdbcDriver) shouldBe false + } + + it should "not include UnlimitedResults" in { + val manager = new DefaultLicenseManager + manager.hasFeature(Feature.UnlimitedResults) shouldBe false + } + + it should "not include AdvancedAggregations" in { + val manager = new DefaultLicenseManager + manager.hasFeature(Feature.AdvancedAggregations) shouldBe false + } + + it should "return Community quotas" in { + val manager = new DefaultLicenseManager + manager.quotas shouldBe Quota.Community + } + + "DefaultLicenseManager with Pro license" should "include FlightSql" in { + val manager = new DefaultLicenseManager + manager.validate("PRO-test-key") + manager.hasFeature(Feature.FlightSql) shouldBe true + } + + it should "include MaterializedViews" in { + val manager = new DefaultLicenseManager + manager.validate("PRO-test-key") + manager.hasFeature(Feature.MaterializedViews) shouldBe true + } + + it should "include JdbcDriver" in { + val manager = new DefaultLicenseManager + manager.validate("PRO-test-key") + manager.hasFeature(Feature.JdbcDriver) shouldBe true + } + + it should "include UnlimitedResults" in { + val manager = new DefaultLicenseManager + manager.validate("PRO-test-key") + manager.hasFeature(Feature.UnlimitedResults) shouldBe true + } + + it should "not include Federation" in { + val manager = new DefaultLicenseManager + manager.validate("PRO-test-key") + manager.hasFeature(Feature.Federation) shouldBe false + } + + it should "return Pro quotas" in { + val manager = new DefaultLicenseManager + manager.validate("PRO-test-key") + manager.quotas shouldBe Quota.Pro + } + + "DefaultLicenseManager with Enterprise license" should "include FlightSql" in { + val manager = new DefaultLicenseManager + manager.validate("ENT-test-key") + manager.hasFeature(Feature.FlightSql) shouldBe true + } + + it should "include Federation" in { + val manager = new DefaultLicenseManager + manager.validate("ENT-test-key") + manager.hasFeature(Feature.Federation) shouldBe true + } + + it should "include all features" in { + val manager = new DefaultLicenseManager + manager.validate("ENT-test-key") + Feature.values.foreach { feature => + manager.hasFeature(feature) shouldBe true + } + } + + it should "return Enterprise quotas" in { + val manager = new DefaultLicenseManager + manager.validate("ENT-test-key") + manager.quotas shouldBe Quota.Enterprise + } + + "LicenseManager trait" should "be source-compatible" in { + val manager: LicenseManager = new DefaultLicenseManager + manager.licenseType shouldBe LicenseType.Community + manager.quotas shouldBe Quota.Community + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala new file mode 100644 index 00000000..2b2a5bd3 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala @@ -0,0 +1,80 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class QuotaSpec extends AnyFlatSpec with Matchers { + + "Quota.Community" should "have maxClusters = Some(2)" in { + Quota.Community.maxClusters shouldBe Some(2) + } + + it should "have maxMaterializedViews = Some(3)" in { + Quota.Community.maxMaterializedViews shouldBe Some(3) + } + + it should "have maxQueryResults = Some(10000)" in { + Quota.Community.maxQueryResults shouldBe Some(10000) + } + + it should "have maxConcurrentQueries = Some(5)" in { + Quota.Community.maxConcurrentQueries shouldBe Some(5) + } + + "Quota.Pro" should "have maxClusters = Some(5)" in { + Quota.Pro.maxClusters shouldBe Some(5) + } + + it should "have maxMaterializedViews = Some(50)" in { + Quota.Pro.maxMaterializedViews shouldBe Some(50) + } + + it should "have maxQueryResults = Some(1000000)" in { + Quota.Pro.maxQueryResults shouldBe Some(1000000) + } + + it should "have maxConcurrentQueries = Some(50)" in { + Quota.Pro.maxConcurrentQueries shouldBe Some(50) + } + + "Quota.Enterprise" should "have maxClusters = None (unlimited)" in { + Quota.Enterprise.maxClusters shouldBe None + } + + it should "have maxMaterializedViews = None (unlimited)" in { + Quota.Enterprise.maxMaterializedViews shouldBe None + } + + it should "have maxQueryResults = None (unlimited)" in { + Quota.Enterprise.maxQueryResults shouldBe None + } + + it should "have maxConcurrentQueries = None (unlimited)" in { + Quota.Enterprise.maxConcurrentQueries shouldBe None + } + + "Quota default constructor" should "use maxClusters = Some(2)" in { + val quota = Quota( + maxMaterializedViews = Some(10), + maxQueryResults = Some(100), + maxConcurrentQueries = Some(1) + ) + quota.maxClusters shouldBe Some(2) + } +} From ce33eefd9cffcca2a4bef924911b33496e531e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Tue, 31 Mar 2026 17:21:44 +0200 Subject: [PATCH 02/11] feat: add JWT license key validation with Ed25519 signature verification Implements Story 5.2: JwtLicenseManager validates JWT license keys offline using Ed25519 (EdDSA) signatures via nimbus-jose-jwt. Includes LicenseKeyVerifier for classpath JWK public key loading, licensingTestkit module with JwtTestHelper for cross-project test reuse, and 40 unit tests on Scala 2.12 + 2.13. Closed Issue #63 Co-Authored-By: Claude Opus 4.6 --- build.sbt | 11 + licensing/build.sbt | 7 +- licensing/src/main/resources/keys/README.md | 23 ++ .../elastic/licensing/JwtLicenseManager.scala | 147 +++++++++++ .../licensing/LicenseKeyVerifier.scala | 67 +++++ .../elastic/licensing/package.scala | 27 +++ .../licensing/FeatureFromStringSpec.scala | 70 ++++++ .../licensing/LicenseTypeFromStringSpec.scala | 48 ++++ licensing/testkit/build.sbt | 5 + .../resources/keys/softclient4es-test.jwk | 1 + .../elastic/licensing/JwtTestHelper.scala | 127 ++++++++++ .../licensing/JwtLicenseManagerSpec.scala | 229 ++++++++++++++++++ .../licensing/LicenseKeyVerifierSpec.scala | 72 ++++++ project/Versions.scala | 6 + 14 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 licensing/src/main/resources/keys/README.md create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureFromStringSpec.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseTypeFromStringSpec.scala create mode 100644 licensing/testkit/build.sbt create mode 100644 licensing/testkit/src/main/resources/keys/softclient4es-test.jwk create mode 100644 licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala create mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala create mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala diff --git a/build.sbt b/build.sbt index dab63ba6..c91d9248 100644 --- a/build.sbt +++ b/build.sbt @@ -108,6 +108,16 @@ lazy val licensing = project moduleSettings ) +lazy val licensingTestkit = Project(id = "softclient4es-licensing-testkit", base = file("licensing/testkit")) + .configs(IntegrationTest) + .settings( + Defaults.itSettings, + moduleSettings + ) + .dependsOn( + licensing % "compile->compile" + ) + lazy val sql = project .in(file("sql")) .configs(IntegrationTest) @@ -570,6 +580,7 @@ lazy val root = project ) .aggregate( licensing, + licensingTestkit, sql, bridge, macros, diff --git a/licensing/build.sbt b/licensing/build.sbt index e73e8a70..6a0967a7 100644 --- a/licensing/build.sbt +++ b/licensing/build.sbt @@ -2,5 +2,10 @@ organization := "app.softnetwork.elastic" name := "softclient4es-licensing" -libraryDependencies += "org.scalatest" %% "scalatest" % Versions.scalatest % Test +libraryDependencies ++= Seq( + "com.nimbusds" % "nimbus-jose-jwt" % Versions.nimbusJoseJwt, + "org.bouncycastle" % "bcprov-jdk18on" % Versions.bouncyCastle, + "com.google.crypto.tink" % "tink" % Versions.tink, + "org.scalatest" %% "scalatest" % Versions.scalatest % Test +) diff --git a/licensing/src/main/resources/keys/README.md b/licensing/src/main/resources/keys/README.md new file mode 100644 index 00000000..c9e5292e --- /dev/null +++ b/licensing/src/main/resources/keys/README.md @@ -0,0 +1,23 @@ +# License Public Keys + +Ed25519 public keys in JWK JSON format (`.jwk` files). + +## Format + +```json +{ + "kty": "OKP", + "crv": "Ed25519", + "x": "", + "kid": "" +} +``` + +## Key Naming + +Files are named `{kid}.jwk` where `kid` matches the JWT `kid` header. +Example: `softclient4es-2026-03.jwk` + +## Adding Keys + +Production keys are generated by the license server and added here during release. diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala new file mode 100644 index 00000000..4bfeed36 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala @@ -0,0 +1,147 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.nimbusds.jose.jwk.OctetKeyPair +import com.nimbusds.jwt.SignedJWT + +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference +import scala.collection.JavaConverters._ +import scala.util.Try + +class JwtLicenseManager( + publicKeyOverride: Option[OctetKeyPair] = None, + expectedIssuer: String = "https://license.softclient4es.com" +) extends LicenseManager { + + private case class LicenseState(licenseKey: LicenseKey, quota: Quota) + + private val state: AtomicReference[LicenseState] = new AtomicReference( + LicenseState( + LicenseKey( + id = "community", + licenseType = LicenseType.Community, + features = Set(Feature.MaterializedViews, Feature.JdbcDriver), + expiresAt = None + ), + Quota.Community + ) + ) + + override def validate(jwt: String): Either[LicenseError, LicenseKey] = { + for { + signed <- parseJwt(jwt) + publicKey <- resolvePublicKey(signed) + _ <- verifySignature(signed, publicKey) + _ <- validateIssuer(signed) + key <- extractLicenseKey(signed) + } yield { + val quota = extractQuota(signed) + state.set(LicenseState(key, quota)) + key + } + } + + override def hasFeature(feature: Feature): Boolean = + state.get().licenseKey.features.contains(feature) + + override def quotas: Quota = state.get().quota + + override def licenseType: LicenseType = state.get().licenseKey.licenseType + + private def parseJwt(jwt: String): Either[LicenseError, SignedJWT] = + Try(SignedJWT.parse(jwt)).toEither.left.map(_ => InvalidLicense("Malformed JWT")) + + private def resolvePublicKey(signed: SignedJWT): Either[LicenseError, OctetKeyPair] = + publicKeyOverride match { + case Some(key) => Right(key) + case None => + Option(signed.getHeader.getKeyID) match { + case Some(kid) => LicenseKeyVerifier.loadPublicKey(kid) + case None => Left(InvalidLicense("Missing key ID (kid) in JWT header")) + } + } + + private def verifySignature( + signed: SignedJWT, + publicKey: OctetKeyPair + ): Either[LicenseError, Unit] = + if (LicenseKeyVerifier.verify(signed, publicKey)) Right(()) + else Left(InvalidLicense("Invalid signature")) + + private def validateIssuer(signed: SignedJWT): Either[LicenseError, Unit] = { + val iss = Option(signed.getJWTClaimsSet.getIssuer).getOrElse("") + if (iss == expectedIssuer) Right(()) + else Left(InvalidLicense(s"Invalid issuer: $iss")) + } + + private def extractLicenseKey(signed: SignedJWT): Either[LicenseError, LicenseKey] = { + val claims = signed.getJWTClaimsSet + + val tierStr = Option(claims.getStringClaim("tier")) + val tier = tierStr.map(LicenseType.fromString).getOrElse(LicenseType.Community) + + val features: Set[Feature] = Option(claims.getStringListClaim("features")) + .map(_.asScala.flatMap(Feature.fromString).toSet) + .getOrElse(Set.empty) + + val expiresAt = Option(claims.getExpirationTime).map(_.toInstant) + + // Check expiry + expiresAt match { + case Some(exp: Instant) if exp.isBefore(Instant.now()) => + Left(ExpiredLicense(exp)) + case _ => + val sub = Option(claims.getSubject).getOrElse("unknown") + + val metadata = Map.newBuilder[String, String] + Option(claims.getStringClaim("org_name")).foreach(v => metadata += ("org_name" -> v)) + Option(claims.getJWTID).foreach(v => metadata += ("jti" -> v)) + Try(Option(claims.getBooleanClaim("trial"))).getOrElse(None) + .foreach(v => metadata += ("trial" -> v.toString)) + + Right( + LicenseKey( + id = sub, + licenseType = tier, + features = features, + expiresAt = expiresAt, + metadata = metadata.result() + ) + ) + } + } + + private def extractQuota(signed: SignedJWT): Quota = { + val claims = signed.getJWTClaimsSet + val quotaObj = Option(claims.getJSONObjectClaim("quotas")) + + def intClaim(key: String): Option[Int] = + quotaObj.flatMap(q => Option(q.get(key))).flatMap { + case n: java.lang.Number => Some(n.intValue()) + case _ => None + } + + Quota( + maxMaterializedViews = intClaim("max_materialized_views"), + maxQueryResults = intClaim("max_result_rows"), + maxConcurrentQueries = intClaim("max_concurrent_queries"), + maxClusters = intClaim("max_clusters") + ) + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala new file mode 100644 index 00000000..a50a85ca --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.nimbusds.jose.crypto.Ed25519Verifier +import com.nimbusds.jose.jwk.OctetKeyPair +import com.nimbusds.jwt.SignedJWT + +import scala.io.Source +import scala.util.{Failure, Success, Try} + +object LicenseKeyVerifier { + + /** Verify a signed JWT against an Ed25519 public key. + * + * @param jws + * the signed JWT to verify + * @param publicKey + * the Ed25519 public key (JWK format) + * @return + * true if the signature is valid + */ + def verify(jws: SignedJWT, publicKey: OctetKeyPair): Boolean = + Try(jws.verify(new Ed25519Verifier(publicKey.toPublicJWK))).getOrElse(false) + + /** Load an Ed25519 public key from the classpath by key ID. + * + * Looks for `keys/{kid}.jwk` on the classpath and parses it as JWK JSON. + * + * @param kid + * the key ID (matches the JWT `kid` header) + * @return + * the public key or a LicenseError + */ + def loadPublicKey(kid: String): Either[LicenseError, OctetKeyPair] = { + val resourcePath = s"keys/$kid.jwk" + Option(getClass.getClassLoader.getResourceAsStream(resourcePath)) match { + case None => + Left(InvalidLicense(s"Unknown key ID: $kid")) + case Some(is) => + val result = Try { + val json = Source.fromInputStream(is, "UTF-8").mkString + OctetKeyPair.parse(json).toPublicJWK.asInstanceOf[OctetKeyPair] + } + is.close() + result match { + case Success(key) => Right(key) + case Failure(ex) => + Left(InvalidLicense(s"Failed to parse key '$kid': ${ex.getMessage}")) + } + } + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index bec47384..e0b70ceb 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -33,6 +33,12 @@ package object licensing { case Pro => Enterprise case Enterprise => Enterprise } + + def fromString(s: String): LicenseType = s.trim.toLowerCase match { + case "pro" => Pro + case "enterprise" => Enterprise + case _ => Community + } } sealed trait Feature @@ -54,6 +60,27 @@ package object licensing { FlightSql, Federation ) + + def fromString(s: String): Option[Feature] = s.trim.toLowerCase match { + case "materialized_views" => Some(MaterializedViews) + case "jdbc_driver" => Some(JdbcDriver) + case "odbc_driver" => Some(OdbcDriver) + case "unlimited_results" => Some(UnlimitedResults) + case "advanced_aggregations" => Some(AdvancedAggregations) + case "flight_sql" => Some(FlightSql) + case "federation" => Some(Federation) + case _ => None + } + + def toSnakeCase(f: Feature): String = f match { + case MaterializedViews => "materialized_views" + case JdbcDriver => "jdbc_driver" + case OdbcDriver => "odbc_driver" + case UnlimitedResults => "unlimited_results" + case AdvancedAggregations => "advanced_aggregations" + case FlightSql => "flight_sql" + case Federation => "federation" + } } case class LicenseKey( diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureFromStringSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureFromStringSpec.scala new file mode 100644 index 00000000..ff61d516 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureFromStringSpec.scala @@ -0,0 +1,70 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class FeatureFromStringSpec extends AnyFlatSpec with Matchers { + + "Feature.fromString" should "map materialized_views" in { + Feature.fromString("materialized_views") shouldBe Some(Feature.MaterializedViews) + } + + it should "map jdbc_driver" in { + Feature.fromString("jdbc_driver") shouldBe Some(Feature.JdbcDriver) + } + + it should "map odbc_driver" in { + Feature.fromString("odbc_driver") shouldBe Some(Feature.OdbcDriver) + } + + it should "map unlimited_results" in { + Feature.fromString("unlimited_results") shouldBe Some(Feature.UnlimitedResults) + } + + it should "map advanced_aggregations" in { + Feature.fromString("advanced_aggregations") shouldBe Some(Feature.AdvancedAggregations) + } + + it should "map flight_sql" in { + Feature.fromString("flight_sql") shouldBe Some(Feature.FlightSql) + } + + it should "map federation" in { + Feature.fromString("federation") shouldBe Some(Feature.Federation) + } + + it should "return None for unknown string" in { + Feature.fromString("warp_drive") shouldBe None + } + + it should "be case-insensitive" in { + Feature.fromString("MATERIALIZED_VIEWS") shouldBe Some(Feature.MaterializedViews) + Feature.fromString("Flight_Sql") shouldBe Some(Feature.FlightSql) + } + + it should "handle whitespace" in { + Feature.fromString(" federation ") shouldBe Some(Feature.Federation) + } + + "Feature.toSnakeCase round-trip" should "work for all features" in { + Feature.values.foreach { f => + Feature.fromString(Feature.toSnakeCase(f)) shouldBe Some(f) + } + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseTypeFromStringSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseTypeFromStringSpec.scala new file mode 100644 index 00000000..b381b29a --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseTypeFromStringSpec.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseTypeFromStringSpec extends AnyFlatSpec with Matchers { + + "LicenseType.fromString" should "map community" in { + LicenseType.fromString("community") shouldBe LicenseType.Community + } + + it should "map pro" in { + LicenseType.fromString("pro") shouldBe LicenseType.Pro + } + + it should "map enterprise" in { + LicenseType.fromString("enterprise") shouldBe LicenseType.Enterprise + } + + it should "default to Community for unknown string" in { + LicenseType.fromString("platinum") shouldBe LicenseType.Community + } + + it should "be case-insensitive" in { + LicenseType.fromString("PRO") shouldBe LicenseType.Pro + LicenseType.fromString("Enterprise") shouldBe LicenseType.Enterprise + } + + it should "handle whitespace" in { + LicenseType.fromString(" pro ") shouldBe LicenseType.Pro + } +} diff --git a/licensing/testkit/build.sbt b/licensing/testkit/build.sbt new file mode 100644 index 00000000..7dbcd5f4 --- /dev/null +++ b/licensing/testkit/build.sbt @@ -0,0 +1,5 @@ +organization := "app.softnetwork.elastic" + +name := "softclient4es-licensing-testkit" + +libraryDependencies += "org.scalatest" %% "scalatest" % Versions.scalatest diff --git a/licensing/testkit/src/main/resources/keys/softclient4es-test.jwk b/licensing/testkit/src/main/resources/keys/softclient4es-test.jwk new file mode 100644 index 00000000..b8b7ec73 --- /dev/null +++ b/licensing/testkit/src/main/resources/keys/softclient4es-test.jwk @@ -0,0 +1 @@ +{"kty":"OKP","crv":"Ed25519","kid":"softclient4es-test","x":"EGBuSwTrahLvXcMhjr042wzUc4Wm0FTrTALpb56PLNg"} diff --git a/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala b/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala new file mode 100644 index 00000000..dfc180f1 --- /dev/null +++ b/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala @@ -0,0 +1,127 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jose.crypto.Ed25519Signer +import com.nimbusds.jose.jwk.OctetKeyPair +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} + +import java.util.Date + +object JwtTestHelper { + + /** Static test key pair — the private key is used to sign test JWTs. + * The corresponding public key is in testkit/src/main/resources/keys/softclient4es-test.jwk + */ + private val keyPairJson: String = + """{"kty":"OKP","d":"AanRaois6uVjNOdq46JyJ57LJdrVX3Q-r4KIGwkm37Y","crv":"Ed25519","kid":"softclient4es-test","x":"EGBuSwTrahLvXcMhjr042wzUc4Wm0FTrTALpb56PLNg"}""" + + val keyPair: OctetKeyPair = OctetKeyPair.parse(keyPairJson) + + val publicKey: OctetKeyPair = keyPair.toPublicJWK.asInstanceOf[OctetKeyPair] + + def signJwt( + claims: JWTClaimsSet, + kid: String = "softclient4es-test", + key: OctetKeyPair = keyPair + ): String = { + val header = new JWSHeader.Builder(JWSAlgorithm.EdDSA).keyID(kid).build() + val signed = new SignedJWT(header, claims) + signed.sign(new Ed25519Signer(key)) + signed.serialize() + } + + def proClaimsBuilder( + expiresAt: Date = new Date(System.currentTimeMillis() + 3600000L) + ): JWTClaimsSet.Builder = + new JWTClaimsSet.Builder() + .issuer("https://license.softclient4es.com") + .subject("org-acme-123") + .claim("tier", "pro") + .claim( + "features", + java.util.Arrays.asList( + "materialized_views", + "jdbc_driver", + "unlimited_results", + "flight_sql" + ) + ) + .claim("quotas", { + val m = new java.util.LinkedHashMap[String, AnyRef]() + m.put("max_materialized_views", Integer.valueOf(50)) + m.put("max_result_rows", Integer.valueOf(1000000)) + m.put("max_concurrent_queries", Integer.valueOf(50)) + m.put("max_clusters", Integer.valueOf(5)) + m + }) + .claim("org_name", "Acme Corp") + .jwtID("lic-001") + .claim("trial", false) + .expirationTime(expiresAt) + + def enterpriseClaimsBuilder( + expiresAt: Date = new Date(System.currentTimeMillis() + 3600000L) + ): JWTClaimsSet.Builder = + new JWTClaimsSet.Builder() + .issuer("https://license.softclient4es.com") + .subject("org-bigcorp-456") + .claim("tier", "enterprise") + .claim( + "features", + java.util.Arrays.asList( + "materialized_views", + "jdbc_driver", + "odbc_driver", + "unlimited_results", + "advanced_aggregations", + "flight_sql", + "federation" + ) + ) + .claim("quotas", { + val m = new java.util.LinkedHashMap[String, AnyRef]() + m + }) + .claim("org_name", "BigCorp Inc") + .jwtID("lic-002") + .expirationTime(expiresAt) + + def communityClaimsBuilder( + expiresAt: Date = new Date(System.currentTimeMillis() + 3600000L) + ): JWTClaimsSet.Builder = + new JWTClaimsSet.Builder() + .issuer("https://license.softclient4es.com") + .subject("org-free-789") + .claim("tier", "community") + .claim( + "features", + java.util.Arrays.asList("materialized_views", "jdbc_driver") + ) + .claim("quotas", { + val m = new java.util.LinkedHashMap[String, AnyRef]() + m.put("max_materialized_views", Integer.valueOf(3)) + m.put("max_result_rows", Integer.valueOf(10000)) + m.put("max_concurrent_queries", Integer.valueOf(5)) + m.put("max_clusters", Integer.valueOf(2)) + m + }) + .claim("org_name", "Free User") + .jwtID("lic-003") + .expirationTime(expiresAt) +} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala new file mode 100644 index 00000000..ecc6c464 --- /dev/null +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala @@ -0,0 +1,229 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator +import com.nimbusds.jwt.JWTClaimsSet +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.util.Date + +class JwtLicenseManagerSpec extends AnyFlatSpec with Matchers { + + private def manager = new JwtLicenseManager( + publicKeyOverride = Some(JwtTestHelper.publicKey) + ) + + "JwtLicenseManager default state" should "be Community tier" in { + val m = manager + m.licenseType shouldBe LicenseType.Community + m.quotas shouldBe Quota.Community + } + + "JwtLicenseManager with valid Pro JWT" should "return Right(LicenseKey) with correct tier" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + val result = m.validate(jwt) + result shouldBe a[Right[_, _]] + val key = result.toOption.get + key.licenseType shouldBe LicenseType.Pro + key.id shouldBe "org-acme-123" + } + + it should "have correct features" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + m.validate(jwt) + m.hasFeature(Feature.MaterializedViews) shouldBe true + m.hasFeature(Feature.JdbcDriver) shouldBe true + m.hasFeature(Feature.UnlimitedResults) shouldBe true + m.hasFeature(Feature.FlightSql) shouldBe true + m.hasFeature(Feature.Federation) shouldBe false + m.hasFeature(Feature.OdbcDriver) shouldBe false + } + + it should "have JWT-embedded quotas (not tier defaults)" in { + val m = manager + val claims = JwtTestHelper.proClaimsBuilder() + .claim("quotas", { + val q = new java.util.LinkedHashMap[String, AnyRef]() + q.put("max_result_rows", Integer.valueOf(500000)) + q.put("max_clusters", Integer.valueOf(3)) + q + }) + .build() + val jwt = JwtTestHelper.signJwt(claims) + m.validate(jwt) + m.quotas.maxQueryResults shouldBe Some(500000) + m.quotas.maxClusters shouldBe Some(3) + m.quotas.maxMaterializedViews shouldBe None // not in JWT → None (unlimited) + m.quotas.maxConcurrentQueries shouldBe None + } + + it should "have correct expiresAt" in { + val m = manager + val expDate = new Date(System.currentTimeMillis() + 7200000L) + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + val result = m.validate(jwt) + val key = result.toOption.get + key.expiresAt shouldBe defined + // JWT exp is truncated to seconds + key.expiresAt.get.getEpochSecond shouldBe (expDate.getTime / 1000) + } + + it should "populate metadata" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + val result = m.validate(jwt) + val key = result.toOption.get + key.metadata("org_name") shouldBe "Acme Corp" + key.metadata("jti") shouldBe "lic-001" + key.metadata("trial") shouldBe "false" + } + + "JwtLicenseManager with valid Enterprise JWT" should "have all 7 features" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) + m.validate(jwt) + Feature.values.foreach { feature => + m.hasFeature(feature) shouldBe true + } + } + + it should "have unlimited quotas when JWT quotas object is empty" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) + m.validate(jwt) + m.quotas.maxMaterializedViews shouldBe None + m.quotas.maxQueryResults shouldBe None + m.quotas.maxConcurrentQueries shouldBe None + m.quotas.maxClusters shouldBe None + } + + "JwtLicenseManager with valid Community JWT" should "have only community features" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.communityClaimsBuilder().build()) + m.validate(jwt) + m.hasFeature(Feature.MaterializedViews) shouldBe true + m.hasFeature(Feature.JdbcDriver) shouldBe true + m.hasFeature(Feature.FlightSql) shouldBe false + m.hasFeature(Feature.Federation) shouldBe false + } + + "JwtLicenseManager with tampered signature" should "return Left(InvalidLicense)" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + // Tamper by replacing a character in the signature part + val parts = jwt.split("\\.") + val tampered = parts(0) + "." + parts(1) + "." + parts(2).reverse + val result = m.validate(tampered) + result shouldBe a[Left[_, _]] + result.left.toOption.get shouldBe a[InvalidLicense] + result.left.toOption.get.message should include("Invalid signature") + } + + "JwtLicenseManager with expired JWT" should "return Left(ExpiredLicense)" in { + val m = manager + val expDate = new Date(System.currentTimeMillis() - 3600000L) // 1 hour ago + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + val result = m.validate(jwt) + result shouldBe a[Left[_, _]] + result.left.toOption.get shouldBe an[ExpiredLicense] + } + + "JwtLicenseManager with unknown kid" should "return Left(InvalidLicense) when no override" in { + val m = new JwtLicenseManager(publicKeyOverride = None) + val jwt = JwtTestHelper.signJwt( + JwtTestHelper.proClaimsBuilder().build(), + kid = "unknown-key-2099" + ) + val result = m.validate(jwt) + result shouldBe a[Left[_, _]] + result.left.toOption.get.message should include("Unknown key ID") + } + + "JwtLicenseManager with wrong issuer" should "return Left(InvalidLicense)" in { + val m = manager + val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) + .issuer("https://evil.example.com") + .build() + val jwt = JwtTestHelper.signJwt(claims) + val result = m.validate(jwt) + result shouldBe a[Left[_, _]] + result.left.toOption.get.message should include("Invalid issuer") + } + + "JwtLicenseManager with malformed string" should "return Left(InvalidLicense)" in { + val m = manager + val result = m.validate("not-a-jwt-at-all") + result shouldBe a[Left[_, _]] + result.left.toOption.get.message should include("Malformed JWT") + } + + "JwtLicenseManager with unknown tier" should "default to Community" in { + val m = manager + val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) + .claim("tier", "platinum") + .build() + val jwt = JwtTestHelper.signJwt(claims) + val result = m.validate(jwt) + result shouldBe a[Right[_, _]] + result.toOption.get.licenseType shouldBe LicenseType.Community + } + + "JwtLicenseManager with unknown feature strings" should "silently ignore them" in { + val m = manager + val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) + .claim("features", java.util.Arrays.asList("materialized_views", "time_travel", "warp_drive")) + .build() + val jwt = JwtTestHelper.signJwt(claims) + val result = m.validate(jwt) + result shouldBe a[Right[_, _]] + val key = result.toOption.get + key.features shouldBe Set(Feature.MaterializedViews) + } + + "JwtLicenseManager re-validation" should "replace the previous license" in { + val m = manager + val proJwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + m.validate(proJwt) + m.licenseType shouldBe LicenseType.Pro + + val entJwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) + m.validate(entJwt) + m.licenseType shouldBe LicenseType.Enterprise + } + + "JwtLicenseManager quota mapping" should "map null/missing to None (unlimited)" in { + val m = manager + val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) + .claim("quotas", { + val q = new java.util.LinkedHashMap[String, AnyRef]() + q.put("max_result_rows", null) + q.put("max_clusters", Integer.valueOf(10)) + q + }) + .build() + val jwt = JwtTestHelper.signJwt(claims) + m.validate(jwt) + m.quotas.maxQueryResults shouldBe None + m.quotas.maxClusters shouldBe Some(10) + m.quotas.maxMaterializedViews shouldBe None + } +} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala new file mode 100644 index 00000000..69b118e0 --- /dev/null +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala @@ -0,0 +1,72 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.nimbusds.jose.jwk.OctetKeyPair +import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseKeyVerifierSpec extends AnyFlatSpec with Matchers { + + "LicenseKeyVerifier.verify" should "return true for a valid signature" in { + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + val signed = SignedJWT.parse(jwt) + LicenseKeyVerifier.verify(signed, JwtTestHelper.publicKey) shouldBe true + } + + it should "return false for a tampered payload" in { + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + val signed = SignedJWT.parse(jwt) + // Tamper: create a new JWT with a different subject but the same signature + val tampered = new SignedJWT( + signed.getHeader.toBase64URL, + new JWTClaimsSet.Builder(signed.getJWTClaimsSet).subject("tampered").build().toPayload.toBase64URL, + signed.getSignature + ) + LicenseKeyVerifier.verify(tampered, JwtTestHelper.publicKey) shouldBe false + } + + it should "return false when verified against a different key" in { + val otherKeyPair = new OctetKeyPairGenerator(Curve.Ed25519) + .keyID("other-key") + .generate() + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + val signed = SignedJWT.parse(jwt) + LicenseKeyVerifier.verify( + signed, + otherKeyPair.toPublicJWK.asInstanceOf[OctetKeyPair] + ) shouldBe false + } + + "LicenseKeyVerifier.loadPublicKey" should "load the test key by kid from classpath" in { + val result = LicenseKeyVerifier.loadPublicKey("softclient4es-test") + result shouldBe a[Right[_, _]] + val key = result.toOption.get + key.getKeyID shouldBe "softclient4es-test" + key.getCurve.getName shouldBe "Ed25519" + } + + it should "return Left for an unknown kid" in { + val result = LicenseKeyVerifier.loadPublicKey("unknown-key-id") + result shouldBe a[Left[_, _]] + result.left.toOption.get shouldBe a[InvalidLicense] + result.left.toOption.get.asInstanceOf[InvalidLicense].reason should include("Unknown key ID") + } +} diff --git a/project/Versions.scala b/project/Versions.scala index e02c0d3e..18b4c77f 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -60,4 +60,10 @@ object Versions { val hadoop = "3.4.2" // must match hadoop-client in core/build.sbt val gcsConnector = "hadoop3-2.2.24" + + val nimbusJoseJwt = "10.3" + + val bouncyCastle = "1.80" + + val tink = "1.16.0" } From 48de84bd583692cb970c3ce4bf3a1e4d482f1c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Tue, 31 Mar 2026 19:41:40 +0200 Subject: [PATCH 03/11] feat: add license configuration and resolution priority chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LicenseConfig (HOCON + env var loading), LicenseResolver (4-step priority: static JWT → API key fetch → disk cache → Community), grace period support in JwtLicenseManager, and LicenseKey.Community constant. Closed Issue #64 Co-Authored-By: Claude Opus 4.6 --- licensing/build.sbt | 10 +- licensing/src/main/resources/reference.conf | 21 ++ .../licensing/DefaultLicenseManager.scala | 10 +- .../elastic/licensing/JwtLicenseManager.scala | 105 ++++--- .../elastic/licensing/LicenseConfig.scala | 65 +++++ .../elastic/licensing/LicenseResolver.scala | 84 ++++++ .../elastic/licensing/package.scala | 9 + .../elastic/licensing/LicenseConfigSpec.scala | 119 ++++++++ .../elastic/licensing/JwtTestHelper.scala | 50 ++-- .../licensing/JwtLicenseManagerSpec.scala | 103 ++++++- .../licensing/LicenseKeyVerifierSpec.scala | 6 +- .../licensing/LicenseResolverSpec.scala | 256 ++++++++++++++++++ 12 files changed, 749 insertions(+), 89 deletions(-) create mode 100644 licensing/src/main/resources/reference.conf create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala create mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala diff --git a/licensing/build.sbt b/licensing/build.sbt index 6a0967a7..f92317c8 100644 --- a/licensing/build.sbt +++ b/licensing/build.sbt @@ -3,9 +3,11 @@ organization := "app.softnetwork.elastic" name := "softclient4es-licensing" libraryDependencies ++= Seq( - "com.nimbusds" % "nimbus-jose-jwt" % Versions.nimbusJoseJwt, - "org.bouncycastle" % "bcprov-jdk18on" % Versions.bouncyCastle, - "com.google.crypto.tink" % "tink" % Versions.tink, - "org.scalatest" %% "scalatest" % Versions.scalatest % Test + "com.nimbusds" % "nimbus-jose-jwt" % Versions.nimbusJoseJwt, + "org.bouncycastle" % "bcprov-jdk18on" % Versions.bouncyCastle, + "com.google.crypto.tink" % "tink" % Versions.tink, + "com.typesafe" % "config" % Versions.typesafeConfig, + "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging, + "org.scalatest" %% "scalatest" % Versions.scalatest % Test ) diff --git a/licensing/src/main/resources/reference.conf b/licensing/src/main/resources/reference.conf new file mode 100644 index 00000000..e47b8c16 --- /dev/null +++ b/licensing/src/main/resources/reference.conf @@ -0,0 +1,21 @@ +softclient4es { + license { + key = "" + key = ${?SOFTCLIENT4ES_LICENSE_KEY} + api-key = "" + api-key = ${?SOFTCLIENT4ES_API_KEY} + + refresh { + enabled = true + interval = 24h + } + + telemetry { + enabled = true + } + + grace-period = 14d + + cache-dir = ${user.home}/.softclient4es + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala index f62cfe59..21bcb583 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala @@ -18,15 +18,7 @@ package app.softnetwork.elastic.licensing class DefaultLicenseManager extends LicenseManager { - private var currentLicense: LicenseKey = LicenseKey( - id = "community", - licenseType = LicenseType.Community, - features = Set( - Feature.MaterializedViews, - Feature.JdbcDriver - ), - expiresAt = None - ) + private var currentLicense: LicenseKey = LicenseKey.Community override def validate(key: String): Either[LicenseError, LicenseKey] = { key match { diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala index 4bfeed36..d89e3d41 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala @@ -19,7 +19,7 @@ package app.softnetwork.elastic.licensing import com.nimbusds.jose.jwk.OctetKeyPair import com.nimbusds.jwt.SignedJWT -import java.time.Instant +import java.time.{Duration, Instant} import java.util.concurrent.atomic.AtomicReference import scala.collection.JavaConverters._ import scala.util.Try @@ -32,37 +32,43 @@ class JwtLicenseManager( private case class LicenseState(licenseKey: LicenseKey, quota: Quota) private val state: AtomicReference[LicenseState] = new AtomicReference( - LicenseState( - LicenseKey( - id = "community", - licenseType = LicenseType.Community, - features = Set(Feature.MaterializedViews, Feature.JdbcDriver), - expiresAt = None - ), - Quota.Community - ) + LicenseState(LicenseKey.Community, Quota.Community) ) - override def validate(jwt: String): Either[LicenseError, LicenseKey] = { + override def validate(jwt: String): Either[LicenseError, LicenseKey] = + doValidate(jwt, gracePeriod = None) + + def validateWithGracePeriod( + jwt: String, + gracePeriod: Duration + ): Either[LicenseError, LicenseKey] = + doValidate(jwt, gracePeriod = Some(gracePeriod)) + + def resetToCommunity(): Unit = + state.set(LicenseState(LicenseKey.Community, Quota.Community)) + + override def hasFeature(feature: Feature): Boolean = + state.get().licenseKey.features.contains(feature) + + override def quotas: Quota = state.get().quota + + override def licenseType: LicenseType = state.get().licenseKey.licenseType + + private def doValidate( + jwt: String, + gracePeriod: Option[Duration] + ): Either[LicenseError, LicenseKey] = for { signed <- parseJwt(jwt) publicKey <- resolvePublicKey(signed) _ <- verifySignature(signed, publicKey) _ <- validateIssuer(signed) - key <- extractLicenseKey(signed) + key <- extractLicenseKey(signed, gracePeriod) } yield { val quota = extractQuota(signed) state.set(LicenseState(key, quota)) key } - } - - override def hasFeature(feature: Feature): Boolean = - state.get().licenseKey.features.contains(feature) - - override def quotas: Quota = state.get().quota - - override def licenseType: LicenseType = state.get().licenseKey.licenseType private def parseJwt(jwt: String): Either[LicenseError, SignedJWT] = Try(SignedJWT.parse(jwt)).toEither.left.map(_ => InvalidLicense("Malformed JWT")) @@ -90,7 +96,10 @@ class JwtLicenseManager( else Left(InvalidLicense(s"Invalid issuer: $iss")) } - private def extractLicenseKey(signed: SignedJWT): Either[LicenseError, LicenseKey] = { + private def extractLicenseKey( + signed: SignedJWT, + gracePeriod: Option[Duration] + ): Either[LicenseError, LicenseKey] = { val claims = signed.getJWTClaimsSet val tierStr = Option(claims.getStringClaim("tier")) @@ -102,31 +111,47 @@ class JwtLicenseManager( val expiresAt = Option(claims.getExpirationTime).map(_.toInstant) - // Check expiry + // Check expiry with optional grace period expiresAt match { case Some(exp: Instant) if exp.isBefore(Instant.now()) => - Left(ExpiredLicense(exp)) + gracePeriod match { + case Some(grace) if exp.plus(grace).isAfter(Instant.now()) => + // Within grace period — allow + buildLicenseKey(claims, tier, features, expiresAt) + case _ => + Left(ExpiredLicense(exp)) + } case _ => - val sub = Option(claims.getSubject).getOrElse("unknown") - - val metadata = Map.newBuilder[String, String] - Option(claims.getStringClaim("org_name")).foreach(v => metadata += ("org_name" -> v)) - Option(claims.getJWTID).foreach(v => metadata += ("jti" -> v)) - Try(Option(claims.getBooleanClaim("trial"))).getOrElse(None) - .foreach(v => metadata += ("trial" -> v.toString)) - - Right( - LicenseKey( - id = sub, - licenseType = tier, - features = features, - expiresAt = expiresAt, - metadata = metadata.result() - ) - ) + buildLicenseKey(claims, tier, features, expiresAt) } } + private def buildLicenseKey( + claims: com.nimbusds.jwt.JWTClaimsSet, + tier: LicenseType, + features: Set[Feature], + expiresAt: Option[Instant] + ): Either[LicenseError, LicenseKey] = { + val sub = Option(claims.getSubject).getOrElse("unknown") + + val metadata = Map.newBuilder[String, String] + Option(claims.getStringClaim("org_name")).foreach(v => metadata += ("org_name" -> v)) + Option(claims.getJWTID).foreach(v => metadata += ("jti" -> v)) + Try(Option(claims.getBooleanClaim("trial"))) + .getOrElse(None) + .foreach(v => metadata += ("trial" -> v.toString)) + + Right( + LicenseKey( + id = sub, + licenseType = tier, + features = features, + expiresAt = expiresAt, + metadata = metadata.result() + ) + ) + } + private def extractQuota(signed: SignedJWT): Quota = { val claims = signed.getJWTClaimsSet val quotaObj = Option(claims.getJSONObjectClaim("quotas")) diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala new file mode 100644 index 00000000..544848df --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala @@ -0,0 +1,65 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.typesafe.config.{Config, ConfigFactory} + +import scala.concurrent.duration._ + +case class LicenseConfig( + key: Option[String], + apiKey: Option[String], + refreshEnabled: Boolean, + refreshInterval: FiniteDuration, + telemetryEnabled: Boolean, + gracePeriod: FiniteDuration, + cacheDir: String +) + +object LicenseConfig { + + private def nonBlank(s: String): Option[String] = + Option(s).map(_.trim).filter(_.nonEmpty) + + def load(): LicenseConfig = load(ConfigFactory.load()) + + def load(config: Config): LicenseConfig = { + val license = config.getConfig("softclient4es.license") + + val key = nonBlank(license.getString("key")) + val apiKey = nonBlank(license.getString("api-key")) + + val refreshEnabled = license.getBoolean("refresh.enabled") + val refreshInterval = license.getDuration("refresh.interval").toMillis.millis + + val telemetryEnabled = license.getBoolean("telemetry.enabled") + + val gracePeriod = license.getDuration("grace-period").toMillis.millis + + val cacheDir = license.getString("cache-dir") + + LicenseConfig( + key = key, + apiKey = apiKey, + refreshEnabled = refreshEnabled, + refreshInterval = refreshInterval, + telemetryEnabled = telemetryEnabled, + gracePeriod = gracePeriod, + cacheDir = cacheDir + ) + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala new file mode 100644 index 00000000..c007e2ee --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala @@ -0,0 +1,84 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.typesafe.scalalogging.LazyLogging + +class LicenseResolver( + config: LicenseConfig, + jwtLicenseManager: JwtLicenseManager, + apiKeyFetcher: Option[String => Either[LicenseError, String]] = None, + cacheReader: Option[() => Option[String]] = None +) extends LazyLogging { + + private val gracePeriod: java.time.Duration = + java.time.Duration.ofMillis(config.gracePeriod.toMillis) + + def resolve(): LicenseKey = { + // Step 1: Static JWT + config.key.foreach { jwt => + jwtLicenseManager.validateWithGracePeriod(jwt, gracePeriod) match { + case Right(key) => + return key + case Left(ExpiredLicense(exp)) => + logger.warn(s"Static JWT expired at $exp (beyond ${config.gracePeriod} grace period)") + case Left(err) => + logger.error(s"Static JWT invalid: ${err.message}") + } + } + + // Step 2: API key fetch + config.apiKey.foreach { apiKey => + apiKeyFetcher.foreach { fetcher => + fetcher(apiKey) match { + case Right(jwt) => + jwtLicenseManager.validate(jwt) match { + case Right(key) => + return key + case Left(err) => + logger.error(s"Fetched JWT is invalid: ${err.message}") + } + case Left(err) => + logger.warn(s"Failed to fetch license: ${err.message}") + } + } + } + + // Step 3: Disk cache + cacheReader.foreach { reader => + reader().foreach { jwt => + jwtLicenseManager.validateWithGracePeriod(jwt, gracePeriod) match { + case Right(key) => + logger.warn("Using cached license") + return key + case Left(_) => // fall through + } + } + } + + // Step 4: Community default + if (config.apiKey.isDefined) { + logger.error("Could not fetch license — falling back to Community mode") + } else if (config.key.isDefined) { + logger.error("License invalid or expired — falling back to Community mode") + } else { + logger.info("Running in Community mode") + } + jwtLicenseManager.resetToCommunity() + LicenseKey.Community + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index e0b70ceb..e2218be3 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -91,6 +91,15 @@ package object licensing { metadata: Map[String, String] = Map.empty ) + object LicenseKey { + val Community: LicenseKey = LicenseKey( + id = "community", + licenseType = LicenseType.Community, + features = Set(Feature.MaterializedViews, Feature.JdbcDriver), + expiresAt = None + ) + } + case class Quota( maxMaterializedViews: Option[Int], // None = unlimited maxQueryResults: Option[Int], // None = unlimited diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala new file mode 100644 index 00000000..df80cfb9 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala @@ -0,0 +1,119 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.typesafe.config.ConfigFactory +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration._ + +class LicenseConfigSpec extends AnyFlatSpec with Matchers { + + private def configFrom(hocon: String): LicenseConfig = + LicenseConfig.load( + ConfigFactory + .parseString(hocon) + .withFallback(ConfigFactory.load()) + ) + + "reference.conf defaults" should "load without explicit HOCON override" in { + val cfg = LicenseConfig.load(ConfigFactory.load()) + cfg.key shouldBe None + cfg.apiKey shouldBe None + cfg.refreshEnabled shouldBe true + cfg.refreshInterval shouldBe 24.hours + cfg.telemetryEnabled shouldBe true + cfg.gracePeriod shouldBe 14.days + cfg.cacheDir should include(".softclient4es") + } + + "HOCON with key" should "extract static JWT" in { + val cfg = configFrom("""softclient4es.license.key = "eyJhbGciOiJFZERTQSJ9.test" """) + cfg.key shouldBe Some("eyJhbGciOiJFZERTQSJ9.test") + } + + "HOCON with api-key" should "extract API key" in { + val cfg = configFrom("""softclient4es.license.api-key = "sk-abc123" """) + cfg.apiKey shouldBe Some("sk-abc123") + } + + "HOCON with neither key nor api-key" should "return None for both" in { + val cfg = configFrom("") // just reference.conf defaults + cfg.key shouldBe None + cfg.apiKey shouldBe None + } + + "HOCON with empty string key" should "treat as absent (blank-as-absent)" in { + val cfg = configFrom("""softclient4es.license.key = "" """) + cfg.key shouldBe None + } + + "HOCON with whitespace-only api-key" should "treat as absent (blank-as-absent)" in { + val cfg = configFrom("""softclient4es.license.api-key = " " """) + cfg.apiKey shouldBe None + } + + "custom refresh settings" should "be parsed correctly" in { + val cfg = configFrom(""" + softclient4es.license.refresh { + enabled = false + interval = 12h + } + """) + cfg.refreshEnabled shouldBe false + cfg.refreshInterval shouldBe 12.hours + } + + "custom telemetry setting" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.telemetry.enabled = false""") + cfg.telemetryEnabled shouldBe false + } + + "custom grace-period" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.grace-period = 7d""") + cfg.gracePeriod shouldBe 7.days + } + + "custom cache-dir" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.cache-dir = "/tmp/test-cache" """) + cfg.cacheDir shouldBe "/tmp/test-cache" + } + + "all custom settings" should "be parsed together" in { + val cfg = configFrom(""" + softclient4es.license { + key = "eyJ.test.jwt" + api-key = "sk-custom" + refresh { + enabled = false + interval = 6h + } + telemetry.enabled = false + grace-period = 30d + cache-dir = "/opt/licenses" + } + """) + cfg.key shouldBe Some("eyJ.test.jwt") + cfg.apiKey shouldBe Some("sk-custom") + cfg.refreshEnabled shouldBe false + cfg.refreshInterval shouldBe 6.hours + cfg.telemetryEnabled shouldBe false + cfg.gracePeriod shouldBe 30.days + cfg.cacheDir shouldBe "/opt/licenses" + } +} diff --git a/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala b/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala index dfc180f1..f864af79 100644 --- a/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala +++ b/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala @@ -25,8 +25,8 @@ import java.util.Date object JwtTestHelper { - /** Static test key pair — the private key is used to sign test JWTs. - * The corresponding public key is in testkit/src/main/resources/keys/softclient4es-test.jwk + /** Static test key pair — the private key is used to sign test JWTs. The corresponding public key + * is in testkit/src/main/resources/keys/softclient4es-test.jwk */ private val keyPairJson: String = """{"kty":"OKP","d":"AanRaois6uVjNOdq46JyJ57LJdrVX3Q-r4KIGwkm37Y","crv":"Ed25519","kid":"softclient4es-test","x":"EGBuSwTrahLvXcMhjr042wzUc4Wm0FTrTALpb56PLNg"}""" @@ -62,14 +62,16 @@ object JwtTestHelper { "flight_sql" ) ) - .claim("quotas", { - val m = new java.util.LinkedHashMap[String, AnyRef]() - m.put("max_materialized_views", Integer.valueOf(50)) - m.put("max_result_rows", Integer.valueOf(1000000)) - m.put("max_concurrent_queries", Integer.valueOf(50)) - m.put("max_clusters", Integer.valueOf(5)) - m - }) + .claim( + "quotas", { + val m = new java.util.LinkedHashMap[String, AnyRef]() + m.put("max_materialized_views", Integer.valueOf(50)) + m.put("max_result_rows", Integer.valueOf(1000000)) + m.put("max_concurrent_queries", Integer.valueOf(50)) + m.put("max_clusters", Integer.valueOf(5)) + m + } + ) .claim("org_name", "Acme Corp") .jwtID("lic-001") .claim("trial", false) @@ -94,10 +96,12 @@ object JwtTestHelper { "federation" ) ) - .claim("quotas", { - val m = new java.util.LinkedHashMap[String, AnyRef]() - m - }) + .claim( + "quotas", { + val m = new java.util.LinkedHashMap[String, AnyRef]() + m + } + ) .claim("org_name", "BigCorp Inc") .jwtID("lic-002") .expirationTime(expiresAt) @@ -113,14 +117,16 @@ object JwtTestHelper { "features", java.util.Arrays.asList("materialized_views", "jdbc_driver") ) - .claim("quotas", { - val m = new java.util.LinkedHashMap[String, AnyRef]() - m.put("max_materialized_views", Integer.valueOf(3)) - m.put("max_result_rows", Integer.valueOf(10000)) - m.put("max_concurrent_queries", Integer.valueOf(5)) - m.put("max_clusters", Integer.valueOf(2)) - m - }) + .claim( + "quotas", { + val m = new java.util.LinkedHashMap[String, AnyRef]() + m.put("max_materialized_views", Integer.valueOf(3)) + m.put("max_result_rows", Integer.valueOf(10000)) + m.put("max_concurrent_queries", Integer.valueOf(5)) + m.put("max_clusters", Integer.valueOf(2)) + m + } + ) .claim("org_name", "Free User") .jwtID("lic-003") .expirationTime(expiresAt) diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala index ecc6c464..0de7a2a4 100644 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala @@ -22,6 +22,7 @@ import com.nimbusds.jwt.JWTClaimsSet import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import java.time.Duration import java.util.Date class JwtLicenseManagerSpec extends AnyFlatSpec with Matchers { @@ -60,13 +61,16 @@ class JwtLicenseManagerSpec extends AnyFlatSpec with Matchers { it should "have JWT-embedded quotas (not tier defaults)" in { val m = manager - val claims = JwtTestHelper.proClaimsBuilder() - .claim("quotas", { - val q = new java.util.LinkedHashMap[String, AnyRef]() - q.put("max_result_rows", Integer.valueOf(500000)) - q.put("max_clusters", Integer.valueOf(3)) - q - }) + val claims = JwtTestHelper + .proClaimsBuilder() + .claim( + "quotas", { + val q = new java.util.LinkedHashMap[String, AnyRef]() + q.put("max_result_rows", Integer.valueOf(500000)) + q.put("max_clusters", Integer.valueOf(3)) + q + } + ) .build() val jwt = JwtTestHelper.signJwt(claims) m.validate(jwt) @@ -213,12 +217,14 @@ class JwtLicenseManagerSpec extends AnyFlatSpec with Matchers { "JwtLicenseManager quota mapping" should "map null/missing to None (unlimited)" in { val m = manager val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) - .claim("quotas", { - val q = new java.util.LinkedHashMap[String, AnyRef]() - q.put("max_result_rows", null) - q.put("max_clusters", Integer.valueOf(10)) - q - }) + .claim( + "quotas", { + val q = new java.util.LinkedHashMap[String, AnyRef]() + q.put("max_result_rows", null) + q.put("max_clusters", Integer.valueOf(10)) + q + } + ) .build() val jwt = JwtTestHelper.signJwt(claims) m.validate(jwt) @@ -226,4 +232,75 @@ class JwtLicenseManagerSpec extends AnyFlatSpec with Matchers { m.quotas.maxClusters shouldBe Some(10) m.quotas.maxMaterializedViews shouldBe None } + + // --- validateWithGracePeriod tests --- + + "validateWithGracePeriod with non-expired JWT" should "return Right (same as validate)" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + val result = m.validateWithGracePeriod(jwt, Duration.ofDays(14)) + result shouldBe a[Right[_, _]] + result.toOption.get.licenseType shouldBe LicenseType.Pro + } + + "validateWithGracePeriod with expired JWT within grace" should "return Right (grace mode)" in { + val m = manager + val expDate = new Date(System.currentTimeMillis() - 3600000L) // 1 hour ago + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + // Grace period of 14 days — 1 hour ago is well within + val result = m.validateWithGracePeriod(jwt, Duration.ofDays(14)) + result shouldBe a[Right[_, _]] + result.toOption.get.licenseType shouldBe LicenseType.Pro + } + + "validateWithGracePeriod with expired JWT beyond grace" should "return Left(ExpiredLicense)" in { + val m = manager + val expDate = new Date(System.currentTimeMillis() - 30L * 24 * 3600000L) // 30 days ago + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + val result = m.validateWithGracePeriod(jwt, Duration.ofDays(14)) + result shouldBe a[Left[_, _]] + result.left.toOption.get shouldBe an[ExpiredLicense] + } + + "validateWithGracePeriod with zero grace" should "reject all expired JWTs" in { + val m = manager + val expDate = new Date(System.currentTimeMillis() - 1000L) // 1 second ago + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + val result = m.validateWithGracePeriod(jwt, Duration.ZERO) + result shouldBe a[Left[_, _]] + result.left.toOption.get shouldBe an[ExpiredLicense] + } + + "validateWithGracePeriod with 365-day grace" should "accept recently expired JWTs" in { + val m = manager + val expDate = new Date(System.currentTimeMillis() - 60L * 24 * 3600000L) // 60 days ago + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + val result = m.validateWithGracePeriod(jwt, Duration.ofDays(365)) + result shouldBe a[Right[_, _]] + result.toOption.get.licenseType shouldBe LicenseType.Pro + } + + // --- resetToCommunity tests --- + + "resetToCommunity after Pro validation" should "revert to Community tier and quotas" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + m.validate(jwt) + m.licenseType shouldBe LicenseType.Pro + + m.resetToCommunity() + m.licenseType shouldBe LicenseType.Community + m.quotas shouldBe Quota.Community + } + + "resetToCommunity then re-validate" should "update state to Pro again" in { + val m = manager + val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + m.validate(jwt) + m.resetToCommunity() + m.licenseType shouldBe LicenseType.Community + + m.validate(jwt) + m.licenseType shouldBe LicenseType.Pro + } } diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala index 69b118e0..2088fa13 100644 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala @@ -37,7 +37,11 @@ class LicenseKeyVerifierSpec extends AnyFlatSpec with Matchers { // Tamper: create a new JWT with a different subject but the same signature val tampered = new SignedJWT( signed.getHeader.toBase64URL, - new JWTClaimsSet.Builder(signed.getJWTClaimsSet).subject("tampered").build().toPayload.toBase64URL, + new JWTClaimsSet.Builder(signed.getJWTClaimsSet) + .subject("tampered") + .build() + .toPayload + .toBase64URL, signed.getSignature ) LicenseKeyVerifier.verify(tampered, JwtTestHelper.publicKey) shouldBe false diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala new file mode 100644 index 00000000..6d53e32f --- /dev/null +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala @@ -0,0 +1,256 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.util.Date +import scala.concurrent.duration._ + +class LicenseResolverSpec extends AnyFlatSpec with Matchers { + + private def manager = new JwtLicenseManager( + publicKeyOverride = Some(JwtTestHelper.publicKey) + ) + + private def defaultConfig( + licenseKey: Option[String] = None, + apiKey: Option[String] = None + ): LicenseConfig = + LicenseConfig( + key = licenseKey, + apiKey = apiKey, + refreshEnabled = true, + refreshInterval = 24.hours, + telemetryEnabled = true, + gracePeriod = 14.days, + cacheDir = "/tmp/test-cache" + ) + + private def validProJwt: String = + JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + + private def expiredJwt(daysAgo: Int): String = { + val expDate = new Date(System.currentTimeMillis() - daysAgo.toLong * 24 * 3600000L) + JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + } + + // --- Step 1: Static JWT --- + + "LicenseResolver with valid static JWT" should "resolve to that JWT's tier" in { + val m = manager + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey =Some(validProJwt)), + jwtLicenseManager = m + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } + + "LicenseResolver with valid static JWT + API key" should "use static JWT, not call API key" in { + val m = manager + var apiKeyCalled = false + val fetcher: String => Either[LicenseError, String] = { _ => + apiKeyCalled = true + Right(validProJwt) + } + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey =Some(validProJwt), apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + apiKeyCalled shouldBe false + } + + "LicenseResolver with expired static JWT within grace period" should "resolve with grace" in { + val m = manager + val jwt = expiredJwt(1) // expired 1 day ago, grace = 14 days + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey =Some(jwt)), + jwtLicenseManager = m + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } + + // --- Step 1 → Step 2 fallthrough --- + + "LicenseResolver with expired static JWT beyond grace + API key" should "call API key fetcher" in { + val m = manager + val jwt = expiredJwt(30) // expired 30 days ago, grace = 14 days + val freshJwt = validProJwt + val fetcher: String => Either[LicenseError, String] = { _ => Right(freshJwt) } + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey =Some(jwt), apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } + + "LicenseResolver with invalid static JWT (bad signature)" should "fall through to API key" in { + val m = manager + val jwt = validProJwt + val tampered = jwt.split("\\.")(0) + "." + jwt.split("\\.")(1) + "." + jwt + .split("\\.")(2) + .reverse + var apiKeyCalled = false + val fetcher: String => Either[LicenseError, String] = { _ => + apiKeyCalled = true + Right(validProJwt) + } + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey =Some(tampered), apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + resolver.resolve() + apiKeyCalled shouldBe true + } + + // --- Step 2: API key --- + + "LicenseResolver with API key but no fetcher" should "skip step 2 and degrade to Community" in { + val m = manager + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = None + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Community + } + + "LicenseResolver with no static JWT + API key fetch succeeds" should "resolve to fetched JWT" in { + val m = manager + val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } + + // --- Step 2 → Step 3 fallthrough --- + + "LicenseResolver with API key fetch fails + cache hit" should "resolve to cached JWT" in { + val m = manager + val cachedJwt = validProJwt + val fetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val reader: () => Option[String] = () => Some(cachedJwt) + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } + + "LicenseResolver with cached JWT expired within grace" should "resolve with grace" in { + val m = manager + val cachedJwt = expiredJwt(1) // 1 day ago, grace = 14 days + val fetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val reader: () => Option[String] = () => Some(cachedJwt) + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } + + "LicenseResolver with cached JWT expired beyond grace" should "degrade to Community" in { + val m = manager + val cachedJwt = expiredJwt(30) + val fetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val reader: () => Option[String] = () => Some(cachedJwt) + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Community + } + + // --- Step 3 → Step 4 fallthrough --- + + "LicenseResolver with API key fetch fails + no cache" should "degrade to Community" in { + val m = manager + val fetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Community + } + + // --- Step 4: Community default --- + + "LicenseResolver with no JWT and no API key" should "default to Community" in { + val m = manager + val resolver = new LicenseResolver( + config = defaultConfig(), + jwtLicenseManager = m + ) + val key = resolver.resolve() + key shouldBe LicenseKey.Community + } + + // --- resetToCommunity on re-resolution --- + + "LicenseResolver degrading to Community" should "reset manager state from prior Pro" in { + val m = manager + // First, resolve with a valid Pro JWT + val resolver1 = new LicenseResolver( + config = defaultConfig(licenseKey =Some(validProJwt)), + jwtLicenseManager = m + ) + resolver1.resolve() + m.licenseType shouldBe LicenseType.Pro + + // Now resolve with nothing → should reset to Community + val resolver2 = new LicenseResolver( + config = defaultConfig(), + jwtLicenseManager = m + ) + resolver2.resolve() + m.licenseType shouldBe LicenseType.Community + m.quotas shouldBe Quota.Community + } +} From d09b93695ed1683bfcb257a40817dd932de81fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 1 Apr 2026 10:04:00 +0200 Subject: [PATCH 04/11] feat: add API key client and JWT fetch with tier change logging Implements automated JWT provisioning via API key (POST /api/v1/licenses/token), persistent instance ID, API key format validation, and directional tier change logging in LicenseResolver. Closed Issue #65 Co-Authored-By: Claude Opus 4.6 --- build.sbt | 5 +- licensing/build.sbt | 1 + licensing/src/main/resources/reference.conf | 1 + .../elastic/licensing/ApiKeyClient.scala | 128 +++++++++ .../elastic/licensing/InstanceId.scala | 48 ++++ .../elastic/licensing/LicenseConfig.scala | 4 + .../elastic/licensing/LicenseResolver.scala | 8 + .../elastic/licensing/package.scala | 5 + .../elastic/licensing/ApiKeyClientSpec.scala | 270 ++++++++++++++++++ .../elastic/licensing/InstanceIdSpec.scala | 87 ++++++ .../elastic/licensing/LicenseConfigSpec.scala | 8 + .../licensing/LicenseResolverSpec.scala | 109 ++++++- 12 files changed, 666 insertions(+), 8 deletions(-) create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala diff --git a/build.sbt b/build.sbt index c91d9248..ddab952b 100644 --- a/build.sbt +++ b/build.sbt @@ -103,9 +103,12 @@ Test / parallelExecution := false lazy val licensing = project .in(file("licensing")) .configs(IntegrationTest) + .enablePlugins(BuildInfoPlugin) .settings( Defaults.itSettings, - moduleSettings + app.softnetwork.Info.infoSettings, + moduleSettings, + buildInfoObject := "LicensingBuildInfo" ) lazy val licensingTestkit = Project(id = "softclient4es-licensing-testkit", base = file("licensing/testkit")) diff --git a/licensing/build.sbt b/licensing/build.sbt index f92317c8..c66efb21 100644 --- a/licensing/build.sbt +++ b/licensing/build.sbt @@ -8,6 +8,7 @@ libraryDependencies ++= Seq( "com.google.crypto.tink" % "tink" % Versions.tink, "com.typesafe" % "config" % Versions.typesafeConfig, "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging, + "com.fasterxml.jackson.core" % "jackson-databind" % Versions.jackson, "org.scalatest" %% "scalatest" % Versions.scalatest % Test ) diff --git a/licensing/src/main/resources/reference.conf b/licensing/src/main/resources/reference.conf index e47b8c16..bef6d662 100644 --- a/licensing/src/main/resources/reference.conf +++ b/licensing/src/main/resources/reference.conf @@ -4,6 +4,7 @@ softclient4es { key = ${?SOFTCLIENT4ES_LICENSE_KEY} api-key = "" api-key = ${?SOFTCLIENT4ES_API_KEY} + api-url = "https://license.softclient4es.com" refresh { enabled = true diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala new file mode 100644 index 00000000..f17c5380 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala @@ -0,0 +1,128 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import app.softnetwork.elastic.LicensingBuildInfo +import com.fasterxml.jackson.databind.ObjectMapper +import com.typesafe.scalalogging.LazyLogging + +import java.io.IOException +import java.net.{HttpURLConnection, SocketTimeoutException, URL} + +import scala.collection.JavaConverters._ + +class ApiKeyClient( + baseUrl: String, + instanceId: String, + connectTimeoutMs: Int = 10000, + readTimeoutMs: Int = 10000 +) extends LazyLogging { + + import ApiKeyClient._ + + def fetchJwt(apiKey: String): Either[LicenseError, String] = { + // Step 1: Validate API key format + if (!apiKey.startsWith("sk-") || apiKey.length <= 3) { + return Left(InvalidLicense("Invalid API key format")) + } + + try { + // Step 2: Build request body + val body = mapper.writeValueAsString( + Map("instance_id" -> instanceId, "version" -> LicensingBuildInfo.version).asJava + ) + + // Step 3: HTTP POST + val url = new URL(baseUrl + TokenPath) + val conn = url.openConnection().asInstanceOf[HttpURLConnection] + try { + conn.setRequestMethod("POST") + conn.setRequestProperty("Authorization", s"Bearer $apiKey") + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", "application/json") + conn.setConnectTimeout(connectTimeoutMs) + conn.setReadTimeout(readTimeoutMs) + conn.setInstanceFollowRedirects(false) + conn.setDoOutput(true) + + val bodyBytes = body.getBytes("UTF-8") + val os = conn.getOutputStream + try { + os.write(bodyBytes) + } finally { + os.close() + } + + // Step 4: Read response + val code = conn.getResponseCode + code match { + case 200 => + val responseBody = readStream(conn.getInputStream) + val tree = mapper.readTree(responseBody) + val jwtNode = tree.get("jwt") + if (jwtNode == null || jwtNode.isNull || !jwtNode.isTextual) { + Left(InvalidLicense("Missing jwt in response")) + } else { + val messageNode = tree.get("message") + if (messageNode != null && !messageNode.isNull && messageNode.isTextual) { + logger.info(messageNode.asText()) + } + Right(jwtNode.asText()) + } + + case 401 => + val errorBody = readStream(conn.getErrorStream) + val message = try { + val tree = mapper.readTree(errorBody) + val msgNode = tree.get("message") + if (msgNode != null && msgNode.isTextual) msgNode.asText() + else "API key rejected (HTTP 401)" + } catch { + case _: Exception => "API key rejected (HTTP 401)" + } + logger.error(message) + Left(InvalidLicense(message)) + + case other => + Left(InvalidLicense(s"Unexpected HTTP status: $other")) + } + } finally { + conn.disconnect() + } + } catch { + case e @ (_: SocketTimeoutException | _: IOException) => + logger.warn(s"Network error fetching license: ${e.getMessage}") + Left(InvalidLicense(s"Network error: ${e.getMessage}")) + } + } + + private def readStream(is: java.io.InputStream): String = { + if (is == null) return "" + val source = scala.io.Source.fromInputStream(is, "UTF-8") + try { + source.mkString + } finally { + source.close() + is.close() + } + } +} + +object ApiKeyClient { + private[licensing] val TokenPath: String = "/api/v1/licenses/token" + private val mapper: ObjectMapper = new ObjectMapper() +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala new file mode 100644 index 00000000..5a17fc4c --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.typesafe.scalalogging.LazyLogging + +import java.nio.file.{Files, Path, Paths} +import java.util.UUID + +object InstanceId extends LazyLogging { + + def getOrCreate(cacheDir: String): String = { + try { + val dir = Paths.get(cacheDir) + if (!Files.exists(dir)) { + Files.createDirectories(dir) + } + val file: Path = dir.resolve("instance-id") + if (Files.exists(file)) { + val content = new String(Files.readAllBytes(file), "UTF-8").trim + if (content.nonEmpty) { + return content + } + } + val id = UUID.randomUUID().toString + Files.write(file, id.getBytes("UTF-8")) + id + } catch { + case e: Exception => + logger.warn(s"Could not persist instance ID: ${e.getMessage}") + UUID.randomUUID().toString + } + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala index 544848df..b45dda42 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala @@ -23,6 +23,7 @@ import scala.concurrent.duration._ case class LicenseConfig( key: Option[String], apiKey: Option[String], + apiUrl: String, refreshEnabled: Boolean, refreshInterval: FiniteDuration, telemetryEnabled: Boolean, @@ -50,11 +51,14 @@ object LicenseConfig { val gracePeriod = license.getDuration("grace-period").toMillis.millis + val apiUrl = license.getString("api-url") + val cacheDir = license.getString("cache-dir") LicenseConfig( key = key, apiKey = apiKey, + apiUrl = apiUrl, refreshEnabled = refreshEnabled, refreshInterval = refreshInterval, telemetryEnabled = telemetryEnabled, diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala index c007e2ee..1c60b75e 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala @@ -46,8 +46,16 @@ class LicenseResolver( apiKeyFetcher.foreach { fetcher => fetcher(apiKey) match { case Right(jwt) => + val oldTier = jwtLicenseManager.licenseType jwtLicenseManager.validate(jwt) match { case Right(key) => + val newTier = key.licenseType + if (newTier != oldTier) { + val direction = + if (newTier.ordinal > oldTier.ordinal) "upgraded" + else "downgraded" + logger.info(s"License $direction from $oldTier to $newTier") + } return key case Left(err) => logger.error(s"Fetched JWT is invalid: ${err.message}") diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index e2218be3..21a6b9c8 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -22,6 +22,11 @@ package object licensing { def isPaid: Boolean = this != LicenseType.Community def isEnterprise: Boolean = this == LicenseType.Enterprise def isPro: Boolean = this == LicenseType.Pro + def ordinal: Int = this match { + case LicenseType.Community => 0 + case LicenseType.Pro => 1 + case LicenseType.Enterprise => 2 + } } object LicenseType { diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala new file mode 100644 index 00000000..3b8fb4fb --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala @@ -0,0 +1,270 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.fasterxml.jackson.databind.ObjectMapper +import com.sun.net.httpserver.{HttpExchange, HttpHandler, HttpServer} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.net.InetSocketAddress + +class ApiKeyClientSpec extends AnyFlatSpec with Matchers { + + private val mapper = new ObjectMapper() + + // --- API key format validation --- + + "ApiKeyClient with invalid API key (no prefix)" should "return InvalidLicense immediately" in { + val client = new ApiKeyClient(baseUrl = "http://localhost:1", instanceId = "test") + val result = client.fetchJwt("abc123") + result shouldBe Left(InvalidLicense("Invalid API key format")) + } + + "ApiKeyClient with empty API key" should "return InvalidLicense immediately" in { + val client = new ApiKeyClient(baseUrl = "http://localhost:1", instanceId = "test") + val result = client.fetchJwt("") + result shouldBe Left(InvalidLicense("Invalid API key format")) + } + + "ApiKeyClient with bare prefix (sk-)" should "return InvalidLicense immediately" in { + val client = new ApiKeyClient(baseUrl = "http://localhost:1", instanceId = "test") + val result = client.fetchJwt("sk-") + result shouldBe Left(InvalidLicense("Invalid API key format")) + } + + "ApiKeyClient with minimal valid key (sk-x)" should "proceed to HTTP call" in { + // This will fail with a network error since localhost:1 is not listening, + // but it proves the format validation passed + val client = new ApiKeyClient( + baseUrl = "http://localhost:1", + instanceId = "test", + connectTimeoutMs = 500 + ) + val result = client.fetchJwt("sk-x") + result.isLeft shouldBe true + result.left.get shouldBe a[InvalidLicense] + result.left.get.asInstanceOf[InvalidLicense].reason should startWith("Network error:") + } + + // --- HTTP 200 success --- + + "ApiKeyClient on HTTP 200 with valid JWT" should "return Right(jwt)" in { + withServer { (server, port) => + var capturedAuth: String = null + var capturedContentType: String = null + var capturedBody: String = null + + server.createContext( + ApiKeyClient.TokenPath, + new HttpHandler { + def handle(exchange: HttpExchange): Unit = { + capturedAuth = exchange.getRequestHeaders.getFirst("Authorization") + capturedContentType = exchange.getRequestHeaders.getFirst("Content-Type") + capturedBody = scala.io.Source.fromInputStream(exchange.getRequestBody, "UTF-8").mkString + val response = + """{"jwt": "eyJhbGciOiJFZERTQSJ9.test.sig", "expires_in": 86400, "message": null}""" + val bytes = response.getBytes("UTF-8") + exchange.sendResponseHeaders(200, bytes.length) + exchange.getResponseBody.write(bytes) + exchange.getResponseBody.close() + } + } + ) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test-instance") + val result = client.fetchJwt("sk-test-key") + + result shouldBe Right("eyJhbGciOiJFZERTQSJ9.test.sig") + capturedAuth shouldBe "Bearer sk-test-key" + capturedContentType shouldBe "application/json" + + // Verify request body contains instance_id and version + val bodyTree = mapper.readTree(capturedBody) + bodyTree.has("instance_id") shouldBe true + bodyTree.get("instance_id").asText() shouldBe "test-instance" + bodyTree.has("version") shouldBe true + } + } + + "ApiKeyClient on HTTP 200 with non-null message" should "return Right(jwt)" in { + withServer { (server, port) => + server.createContext( + ApiKeyClient.TokenPath, + jsonHandler( + 200, + """{"jwt": "eyJ.test", "expires_in": 86400, "message": "Your license expires in 7 days"}""" + ) + ) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") + val result = client.fetchJwt("sk-test") + result shouldBe Right("eyJ.test") + } + } + + // --- HTTP 200 missing jwt --- + + "ApiKeyClient on HTTP 200 with missing jwt field" should "return InvalidLicense" in { + withServer { (server, port) => + server.createContext( + ApiKeyClient.TokenPath, + jsonHandler(200, """{"message": "ok"}""") + ) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") + val result = client.fetchJwt("sk-test") + result shouldBe Left(InvalidLicense("Missing jwt in response")) + } + } + + "ApiKeyClient on HTTP 200 with null jwt" should "return InvalidLicense" in { + withServer { (server, port) => + server.createContext( + ApiKeyClient.TokenPath, + jsonHandler(200, """{"jwt": null}""") + ) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") + val result = client.fetchJwt("sk-test") + result shouldBe Left(InvalidLicense("Missing jwt in response")) + } + } + + // --- HTTP 401 --- + + "ApiKeyClient on HTTP 401" should "return InvalidLicense with backend message" in { + withServer { (server, port) => + server.createContext( + ApiKeyClient.TokenPath, + jsonHandler( + 401, + """{"error": "api_key_revoked", "message": "API key has been revoked"}""" + ) + ) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") + val result = client.fetchJwt("sk-test") + result shouldBe Left(InvalidLicense("API key has been revoked")) + } + } + + "ApiKeyClient on HTTP 401 with malformed body" should "return fallback message" in { + withServer { (server, port) => + server.createContext( + ApiKeyClient.TokenPath, + jsonHandler(401, "not json") + ) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") + val result = client.fetchJwt("sk-test") + result shouldBe Left(InvalidLicense("API key rejected (HTTP 401)")) + } + } + + // --- Unexpected HTTP status --- + + "ApiKeyClient on HTTP 500" should "return InvalidLicense with status code" in { + withServer { (server, port) => + server.createContext( + ApiKeyClient.TokenPath, + jsonHandler(500, """{"error": "internal"}""") + ) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") + val result = client.fetchJwt("sk-test") + result shouldBe Left(InvalidLicense("Unexpected HTTP status: 500")) + } + } + + // --- Network error --- + + "ApiKeyClient with unreachable server" should "return InvalidLicense with network error" in { + // Start server, capture port, stop immediately -> guaranteed ConnectException + val server = HttpServer.create(new InetSocketAddress(0), 0) + server.start() + val port = server.getAddress.getPort + server.stop(0) + + val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") + val result = client.fetchJwt("sk-test") + result.isLeft shouldBe true + result.left.get.asInstanceOf[InvalidLicense].reason should startWith("Network error:") + } + + // --- Timeout --- + + "ApiKeyClient with slow server" should "return InvalidLicense on timeout" in { + withServer { (server, port) => + server.createContext( + ApiKeyClient.TokenPath, + new HttpHandler { + def handle(exchange: HttpExchange): Unit = { + Thread.sleep(5000) + val bytes = "{}".getBytes("UTF-8") + exchange.sendResponseHeaders(200, bytes.length) + exchange.getResponseBody.write(bytes) + exchange.getResponseBody.close() + } + } + ) + + val client = new ApiKeyClient( + baseUrl = s"http://localhost:$port", + instanceId = "test", + readTimeoutMs = 1000 + ) + val result = client.fetchJwt("sk-test") + result.isLeft shouldBe true + result.left.get.asInstanceOf[InvalidLicense].reason should startWith("Network error:") + } + } + + // --- Helpers --- + + private def withServer(f: (HttpServer, Int) => Unit): Unit = { + val server = HttpServer.create(new InetSocketAddress(0), 0) + server.start() + val port = server.getAddress.getPort + try { + f(server, port) + } finally { + server.stop(0) + } + } + + private def jsonHandler(statusCode: Int, body: String): HttpHandler = { + new HttpHandler { + def handle(exchange: HttpExchange): Unit = { + // Consume request body to avoid broken pipe + val is = exchange.getRequestBody + while (is.read() != -1) {} + is.close() + + val bytes = body.getBytes("UTF-8") + if (statusCode >= 400) { + exchange.sendResponseHeaders(statusCode, bytes.length) + exchange.getResponseBody.write(bytes) + } else { + exchange.sendResponseHeaders(statusCode, bytes.length) + exchange.getResponseBody.write(bytes) + } + exchange.getResponseBody.close() + } + } + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala new file mode 100644 index 00000000..d89302aa --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.nio.file.Files + +class InstanceIdSpec extends AnyFlatSpec with Matchers { + + "InstanceId.getOrCreate with empty dir" should "generate UUID and write file" in { + val dir = Files.createTempDirectory("instance-id-test").toFile + try { + val id = InstanceId.getOrCreate(dir.getAbsolutePath) + id should not be empty + // UUID format check + id should fullyMatch regex "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + // File should exist + val file = new java.io.File(dir, "instance-id") + file.exists() shouldBe true + new String(Files.readAllBytes(file.toPath), "UTF-8") shouldBe id + } finally { + deleteDir(dir) + } + } + + "InstanceId.getOrCreate with existing file" should "return same UUID" in { + val dir = Files.createTempDirectory("instance-id-test").toFile + try { + val id1 = InstanceId.getOrCreate(dir.getAbsolutePath) + val id2 = InstanceId.getOrCreate(dir.getAbsolutePath) + id2 shouldBe id1 + } finally { + deleteDir(dir) + } + } + + "InstanceId.getOrCreate with non-existent dir" should "create dir and generate UUID" in { + val parent = Files.createTempDirectory("instance-id-test").toFile + val dir = new java.io.File(parent, "sub/deep") + try { + dir.exists() shouldBe false + val id = InstanceId.getOrCreate(dir.getAbsolutePath) + id should not be empty + dir.exists() shouldBe true + new java.io.File(dir, "instance-id").exists() shouldBe true + } finally { + deleteDir(parent) + } + } + + "InstanceId.getOrCreate with unwritable dir" should "return fresh UUID without persisting" in { + val dir = Files.createTempDirectory("instance-id-test").toFile + try { + dir.setWritable(false) + val id = InstanceId.getOrCreate(dir.getAbsolutePath) + id should not be empty + // File should NOT exist since dir is not writable + new java.io.File(dir, "instance-id").exists() shouldBe false + } finally { + dir.setWritable(true) + deleteDir(dir) + } + } + + private def deleteDir(dir: java.io.File): Unit = { + if (dir.isDirectory) { + Option(dir.listFiles()).foreach(_.foreach(deleteDir)) + } + dir.delete() + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala index df80cfb9..bf44e11a 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala @@ -39,6 +39,7 @@ class LicenseConfigSpec extends AnyFlatSpec with Matchers { cfg.refreshInterval shouldBe 24.hours cfg.telemetryEnabled shouldBe true cfg.gracePeriod shouldBe 14.days + cfg.apiUrl shouldBe "https://license.softclient4es.com" cfg.cacheDir should include(".softclient4es") } @@ -89,6 +90,11 @@ class LicenseConfigSpec extends AnyFlatSpec with Matchers { cfg.gracePeriod shouldBe 7.days } + "custom api-url" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.api-url = "https://custom.example.com" """) + cfg.apiUrl shouldBe "https://custom.example.com" + } + "custom cache-dir" should "be parsed correctly" in { val cfg = configFrom("""softclient4es.license.cache-dir = "/tmp/test-cache" """) cfg.cacheDir shouldBe "/tmp/test-cache" @@ -105,11 +111,13 @@ class LicenseConfigSpec extends AnyFlatSpec with Matchers { } telemetry.enabled = false grace-period = 30d + api-url = "https://staging.license.softclient4es.com" cache-dir = "/opt/licenses" } """) cfg.key shouldBe Some("eyJ.test.jwt") cfg.apiKey shouldBe Some("sk-custom") + cfg.apiUrl shouldBe "https://staging.license.softclient4es.com" cfg.refreshEnabled shouldBe false cfg.refreshInterval shouldBe 6.hours cfg.telemetryEnabled shouldBe false diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala index 6d53e32f..d9323d64 100644 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala @@ -35,6 +35,7 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { LicenseConfig( key = licenseKey, apiKey = apiKey, + apiUrl = "https://license.softclient4es.com", refreshEnabled = true, refreshInterval = 24.hours, telemetryEnabled = true, @@ -55,7 +56,7 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { "LicenseResolver with valid static JWT" should "resolve to that JWT's tier" in { val m = manager val resolver = new LicenseResolver( - config = defaultConfig(licenseKey =Some(validProJwt)), + config = defaultConfig(licenseKey = Some(validProJwt)), jwtLicenseManager = m ) val key = resolver.resolve() @@ -70,7 +71,7 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { Right(validProJwt) } val resolver = new LicenseResolver( - config = defaultConfig(licenseKey =Some(validProJwt), apiKey = Some("sk-test")), + config = defaultConfig(licenseKey = Some(validProJwt), apiKey = Some("sk-test")), jwtLicenseManager = m, apiKeyFetcher = Some(fetcher) ) @@ -83,7 +84,7 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { val m = manager val jwt = expiredJwt(1) // expired 1 day ago, grace = 14 days val resolver = new LicenseResolver( - config = defaultConfig(licenseKey =Some(jwt)), + config = defaultConfig(licenseKey = Some(jwt)), jwtLicenseManager = m ) val key = resolver.resolve() @@ -98,7 +99,7 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { val freshJwt = validProJwt val fetcher: String => Either[LicenseError, String] = { _ => Right(freshJwt) } val resolver = new LicenseResolver( - config = defaultConfig(licenseKey =Some(jwt), apiKey = Some("sk-test")), + config = defaultConfig(licenseKey = Some(jwt), apiKey = Some("sk-test")), jwtLicenseManager = m, apiKeyFetcher = Some(fetcher) ) @@ -118,7 +119,7 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { Right(validProJwt) } val resolver = new LicenseResolver( - config = defaultConfig(licenseKey =Some(tampered), apiKey = Some("sk-test")), + config = defaultConfig(licenseKey = Some(tampered), apiKey = Some("sk-test")), jwtLicenseManager = m, apiKeyFetcher = Some(fetcher) ) @@ -139,7 +140,7 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { key.licenseType shouldBe LicenseType.Community } - "LicenseResolver with no static JWT + API key fetch succeeds" should "resolve to fetched JWT" in { + "LicenseResolver with no static JWT + API key fetch succeeds" should "resolve to fetched JWT and store in manager" in { val m = manager val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } val resolver = new LicenseResolver( @@ -149,6 +150,9 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { ) val key = resolver.resolve() key.licenseType shouldBe LicenseType.Pro + // AC #8: JWT stored in memory for per-request enforcement + m.licenseType shouldBe LicenseType.Pro + m.quotas shouldBe Quota.Pro } // --- Step 2 → Step 3 fallthrough --- @@ -234,11 +238,102 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { // --- resetToCommunity on re-resolution --- + // --- Tier change logging (AC #7) --- + + "LicenseResolver cold start upgrade (Community -> Pro)" should "resolve to Pro with correct manager state" in { + val m = manager + // Fresh manager defaults to Community + m.licenseType shouldBe LicenseType.Community + val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + m.licenseType shouldBe LicenseType.Pro + m.quotas shouldBe Quota.Pro + } + + "LicenseResolver upgrade (Pro -> Enterprise)" should "resolve to Enterprise" in { + val m = manager + // First resolve with Pro via static key + val proJwt = validProJwt + val resolver1 = new LicenseResolver( + config = defaultConfig(licenseKey = Some(proJwt)), + jwtLicenseManager = m + ) + resolver1.resolve() + m.licenseType shouldBe LicenseType.Pro + + // Second resolve with Enterprise via API key fetcher + val entJwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) + val fetcher: String => Either[LicenseError, String] = { _ => Right(entJwt) } + val resolver2 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver2.resolve() + key.licenseType shouldBe LicenseType.Enterprise + m.licenseType shouldBe LicenseType.Enterprise + } + + "LicenseResolver downgrade (Pro -> Community)" should "resolve to Community" in { + val m = manager + // First resolve with Pro via static key + val resolver1 = new LicenseResolver( + config = defaultConfig(licenseKey = Some(validProJwt)), + jwtLicenseManager = m + ) + resolver1.resolve() + m.licenseType shouldBe LicenseType.Pro + + // Second resolve with Community via API key fetcher + val communityJwt = JwtTestHelper.signJwt(JwtTestHelper.communityClaimsBuilder().build()) + val fetcher: String => Either[LicenseError, String] = { _ => Right(communityJwt) } + val resolver2 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver2.resolve() + key.licenseType shouldBe LicenseType.Community + m.licenseType shouldBe LicenseType.Community + m.quotas shouldBe Quota.Community + } + + "LicenseResolver same tier (Pro -> Pro)" should "resolve to Pro without tier change" in { + val m = manager + // First resolve with Pro via static key + val resolver1 = new LicenseResolver( + config = defaultConfig(licenseKey = Some(validProJwt)), + jwtLicenseManager = m + ) + resolver1.resolve() + m.licenseType shouldBe LicenseType.Pro + + // Second resolve with different Pro JWT via API key fetcher + val anotherProJwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + val fetcher: String => Either[LicenseError, String] = { _ => Right(anotherProJwt) } + val resolver2 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver2.resolve() + key.licenseType shouldBe LicenseType.Pro + m.licenseType shouldBe LicenseType.Pro + } + + // --- resetToCommunity on re-resolution --- + "LicenseResolver degrading to Community" should "reset manager state from prior Pro" in { val m = manager // First, resolve with a valid Pro JWT val resolver1 = new LicenseResolver( - config = defaultConfig(licenseKey =Some(validProJwt)), + config = defaultConfig(licenseKey = Some(validProJwt)), jwtLicenseManager = m ) resolver1.resolve() From bcc33340f3b7cb0dafb4750ae9376825b7ad2dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 1 Apr 2026 10:05:07 +0200 Subject: [PATCH 05/11] minor update (formatting) --- .../elastic/licensing/ApiKeyClient.scala | 17 +++++++++-------- .../elastic/licensing/ApiKeyClientSpec.scala | 6 ++++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala index f17c5380..1a5f0fbf 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala @@ -86,14 +86,15 @@ class ApiKeyClient( case 401 => val errorBody = readStream(conn.getErrorStream) - val message = try { - val tree = mapper.readTree(errorBody) - val msgNode = tree.get("message") - if (msgNode != null && msgNode.isTextual) msgNode.asText() - else "API key rejected (HTTP 401)" - } catch { - case _: Exception => "API key rejected (HTTP 401)" - } + val message = + try { + val tree = mapper.readTree(errorBody) + val msgNode = tree.get("message") + if (msgNode != null && msgNode.isTextual) msgNode.asText() + else "API key rejected (HTTP 401)" + } catch { + case _: Exception => "API key rejected (HTTP 401)" + } logger.error(message) Left(InvalidLicense(message)) diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala index 3b8fb4fb..33204706 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala @@ -75,7 +75,8 @@ class ApiKeyClientSpec extends AnyFlatSpec with Matchers { def handle(exchange: HttpExchange): Unit = { capturedAuth = exchange.getRequestHeaders.getFirst("Authorization") capturedContentType = exchange.getRequestHeaders.getFirst("Content-Type") - capturedBody = scala.io.Source.fromInputStream(exchange.getRequestBody, "UTF-8").mkString + capturedBody = + scala.io.Source.fromInputStream(exchange.getRequestBody, "UTF-8").mkString val response = """{"jwt": "eyJhbGciOiJFZERTQSJ9.test.sig", "expires_in": 86400, "message": null}""" val bytes = response.getBytes("UTF-8") @@ -86,7 +87,8 @@ class ApiKeyClientSpec extends AnyFlatSpec with Matchers { } ) - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test-instance") + val client = + new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test-instance") val result = client.fetchJwt("sk-test-key") result shouldBe Right("eyJhbGciOiJFZERTQSJ9.test.sig") From 4f4b5dcb9be76eabfb2fb36a82995ab7addc0913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 1 Apr 2026 10:08:51 +0200 Subject: [PATCH 06/11] update api key client spec with slow server --- .../app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala index 33204706..037961eb 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala @@ -211,12 +211,13 @@ class ApiKeyClientSpec extends AnyFlatSpec with Matchers { // --- Timeout --- "ApiKeyClient with slow server" should "return InvalidLicense on timeout" in { + val timeout = 100 withServer { (server, port) => server.createContext( ApiKeyClient.TokenPath, new HttpHandler { def handle(exchange: HttpExchange): Unit = { - Thread.sleep(5000) + Thread.sleep(timeout * 2) val bytes = "{}".getBytes("UTF-8") exchange.sendResponseHeaders(200, bytes.length) exchange.getResponseBody.write(bytes) @@ -228,7 +229,7 @@ class ApiKeyClientSpec extends AnyFlatSpec with Matchers { val client = new ApiKeyClient( baseUrl = s"http://localhost:$port", instanceId = "test", - readTimeoutMs = 1000 + readTimeoutMs = timeout ) val result = client.fetchJwt("sk-test") result.isLeft shouldBe true From 113a6de1655689483e44650a5114284079ebb91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 2 Apr 2026 05:48:25 +0200 Subject: [PATCH 07/11] feat: add disk cache and offline fallback for JWT license persistence LicenseCache provides atomic write (temp+rename), symlink/size guards, and defensive error handling. LicenseResolver gains cacheWriter (Step 2) and cacheInvalidator (Step 3) callbacks for write-on-fetch and delete-on-stale semantics. Closed Issue #66 Co-Authored-By: Claude Opus 4.6 --- .../elastic/licensing/LicenseCache.scala | 99 ++++++++++ .../elastic/licensing/LicenseResolver.scala | 22 ++- .../elastic/licensing/LicenseCacheSpec.scala | 124 ++++++++++++ .../licensing/LicenseResolverSpec.scala | 183 ++++++++++++++++++ 4 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala new file mode 100644 index 00000000..2df757ea --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala @@ -0,0 +1,99 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import java.nio.file.{Files, Paths, StandardCopyOption} + +import com.typesafe.scalalogging.LazyLogging + +class LicenseCache(cacheDir: String) extends LazyLogging { + + import LicenseCache._ + + def read(): Option[String] = { + try { + val file = Paths.get(cacheDir).resolve(CacheFileName) + if (!Files.exists(file)) return None + if (Files.isSymbolicLink(file)) { + logger.warn("License cache file is a symbolic link, ignoring") + return None + } + val size = Files.size(file) + if (size > MaxCacheFileSizeBytes) { + logger.warn(s"License cache file is unexpectedly large ($size bytes), ignoring") + return None + } + val content = new String(Files.readAllBytes(file), "UTF-8").trim + if (content.isEmpty) None else Some(content) + } catch { + case e: Exception => + logger.warn(s"Could not read license cache: ${e.getMessage}") + None + } + } + + def write(jwt: String): Unit = { + try { + val dir = Paths.get(cacheDir) + if (!Files.exists(dir)) { + Files.createDirectories(dir) + } + val target = dir.resolve(CacheFileName) + val tmp = Files.createTempFile(dir, ".license-cache", ".tmp") + try { + Files.write(tmp, jwt.getBytes("UTF-8")) + try { + Files.move( + tmp, + target, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch { + case _: java.nio.file.AtomicMoveNotSupportedException => + logger.warn( + "Filesystem does not support atomic move — using non-atomic overwrite" + ) + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING) + } + } catch { + case e: Exception => + try { Files.deleteIfExists(tmp) } + catch { case _: Exception => } + throw e + } + } catch { + case e: Exception => + logger.warn(s"Could not write license cache: ${e.getMessage}") + } + } + + def delete(): Unit = { + try { + val file = Paths.get(cacheDir).resolve(CacheFileName) + Files.deleteIfExists(file) + } catch { + case e: Exception => + logger.warn(s"Could not delete license cache: ${e.getMessage}") + } + } +} + +object LicenseCache { + private[licensing] val CacheFileName: String = "license-cache.jwt" + private val MaxCacheFileSizeBytes: Long = 65536L +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala index 1c60b75e..a3b151ea 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala @@ -22,7 +22,9 @@ class LicenseResolver( config: LicenseConfig, jwtLicenseManager: JwtLicenseManager, apiKeyFetcher: Option[String => Either[LicenseError, String]] = None, - cacheReader: Option[() => Option[String]] = None + cacheReader: Option[() => Option[String]] = None, + cacheWriter: Option[String => Unit] = None, + cacheInvalidator: Option[() => Unit] = None ) extends LazyLogging { private val gracePeriod: java.time.Duration = @@ -56,6 +58,14 @@ class LicenseResolver( else "downgraded" logger.info(s"License $direction from $oldTier to $newTier") } + // Write to disk cache for offline fallback + cacheWriter.foreach { writer => + try { writer(jwt) } + catch { + case e: Exception => + logger.warn(s"Could not write license to cache: ${e.getMessage}") + } + } return key case Left(err) => logger.error(s"Fetched JWT is invalid: ${err.message}") @@ -73,7 +83,15 @@ class LicenseResolver( case Right(key) => logger.warn("Using cached license") return key - case Left(_) => // fall through + case Left(_) => + // Cached JWT is invalid or expired beyond grace — delete it + cacheInvalidator.foreach { invalidate => + try { invalidate() } + catch { + case e: Exception => + logger.warn(s"Could not invalidate license cache: ${e.getMessage}") + } + } } } } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala new file mode 100644 index 00000000..4d632521 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala @@ -0,0 +1,124 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.BeforeAndAfterEach + +import java.nio.file.{Files, Path} +import java.util.Comparator + +class LicenseCacheSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach { + + private var tempDir: Path = _ + + override def beforeEach(): Unit = { + tempDir = Files.createTempDirectory("license-cache-test") + } + + override def afterEach(): Unit = { + if (tempDir != null && Files.exists(tempDir)) { + // Walk in reverse order (deepest first) to delete nested directories + val stream = Files.walk(tempDir) + try { + stream.sorted(Comparator.reverseOrder[Path]()).forEach(f => Files.deleteIfExists(f)) + } finally { stream.close() } + } + } + + "LicenseCache write + read" should "round-trip a JWT string" in { + val cache = new LicenseCache(tempDir.toString) + val jwt = "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ0ZXN0In0.signature" + cache.write(jwt) + cache.read() shouldBe Some(jwt) + Files.exists(tempDir.resolve(LicenseCache.CacheFileName)) shouldBe true + } + + "LicenseCache read with no cache file" should "return None" in { + val cache = new LicenseCache(tempDir.toString) + cache.read() shouldBe None + } + + "LicenseCache read with empty cache file" should "return None" in { + Files.write(tempDir.resolve(LicenseCache.CacheFileName), Array.emptyByteArray) + val cache = new LicenseCache(tempDir.toString) + cache.read() shouldBe None + } + + "LicenseCache write with missing directory" should "create directory and write" in { + val nested = tempDir.resolve("subdir").resolve("nested") + val cache = new LicenseCache(nested.toString) + cache.write("jwt-string") + cache.read() shouldBe Some("jwt-string") + } + + "LicenseCache write" should "overwrite existing cache" in { + val cache = new LicenseCache(tempDir.toString) + cache.write("jwt-1") + cache.read() shouldBe Some("jwt-1") + cache.write("jwt-2") + cache.read() shouldBe Some("jwt-2") + } + + "LicenseCache delete" should "remove cache file" in { + val cache = new LicenseCache(tempDir.toString) + cache.write("jwt-to-delete") + Files.exists(tempDir.resolve(LicenseCache.CacheFileName)) shouldBe true + cache.delete() + Files.exists(tempDir.resolve(LicenseCache.CacheFileName)) shouldBe false + } + + "LicenseCache delete on missing file" should "not throw" in { + val cache = new LicenseCache(tempDir.toString) + noException should be thrownBy cache.delete() + } + + "LicenseCache read with I/O error" should "return None" in { + // Create a directory named license-cache.jwt — readAllBytes throws IOException on a directory + Files.createDirectory(tempDir.resolve(LicenseCache.CacheFileName)) + val cache = new LicenseCache(tempDir.toString) + cache.read() shouldBe None + } + + "LicenseCache write with I/O error" should "not throw" in { + // Use a regular file as the cacheDir — createDirectories will fail + val blockerFile = Files.createTempFile("license-cache-blocker", ".tmp") + try { + val cache = new LicenseCache(blockerFile.toString) + noException should be thrownBy cache.write("jwt") + } finally { + Files.deleteIfExists(blockerFile) + } + } + + "LicenseCache read with oversized file" should "return None" in { + val bigContent = new Array[Byte](65537) + java.util.Arrays.fill(bigContent, 'x'.toByte) + Files.write(tempDir.resolve(LicenseCache.CacheFileName), bigContent) + val cache = new LicenseCache(tempDir.toString) + cache.read() shouldBe None + } + + "LicenseCache read with symbolic link" should "return None" in { + val target = Files.createFile(tempDir.resolve("target.txt")) + Files.write(target, "jwt-content".getBytes("UTF-8")) + Files.createSymbolicLink(tempDir.resolve(LicenseCache.CacheFileName), target) + val cache = new LicenseCache(tempDir.toString) + cache.read() shouldBe None + } +} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala index d9323d64..00436ece 100644 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala @@ -19,6 +19,7 @@ package app.softnetwork.elastic.licensing import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import java.nio.file.Files import java.util.Date import scala.concurrent.duration._ @@ -348,4 +349,186 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { m.licenseType shouldBe LicenseType.Community m.quotas shouldBe Quota.Community } + + // --- Cache writer/invalidator (Story 5.5) --- + + "LicenseResolver cache writer on API key fetch success" should "write JWT to cache" in { + val m = manager + var cachedJwt: Option[String] = None + val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } + val writer: String => Unit = { jwt => cachedJwt = Some(jwt) } + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheWriter = Some(writer) + ) + resolver.resolve() + cachedJwt shouldBe Some(validProJwt) + } + + "LicenseResolver cache writer on static JWT success" should "not write to cache" in { + val m = manager + var cacheWriteCalled = false + val writer: String => Unit = { _ => cacheWriteCalled = true } + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey = Some(validProJwt)), + jwtLicenseManager = m, + cacheWriter = Some(writer) + ) + resolver.resolve() + cacheWriteCalled shouldBe false + } + + "LicenseResolver cache invalidator on stale cached JWT" should "delete cache file" in { + val m = manager + var invalidateCalled = false + val invalidator: () => Unit = () => { invalidateCalled = true } + val staleJwt = expiredJwt(30) // expired beyond 14-day grace + val fetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val reader: () => Option[String] = () => Some(staleJwt) + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader), + cacheInvalidator = Some(invalidator) + ) + resolver.resolve() + invalidateCalled shouldBe true + } + + "LicenseResolver cache invalidator on successful resolution" should "not be called" in { + val m = manager + var invalidateCalled = false + val invalidator: () => Unit = () => { invalidateCalled = true } + val cachedJwt = validProJwt + val fetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val reader: () => Option[String] = () => Some(cachedJwt) + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader), + cacheInvalidator = Some(invalidator) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + invalidateCalled shouldBe false + } + + "LicenseResolver cache round-trip" should "use cached JWT when API key fetch fails on second resolve" in { + val m = manager + var cachedJwt: Option[String] = None + val proJwt = validProJwt + + // First resolve: API key succeeds, writes to cache + var fetchSucceeds = true + val fetcher: String => Either[LicenseError, String] = { _ => + if (fetchSucceeds) Right(proJwt) + else Left(InvalidLicense("Network error")) + } + val writer: String => Unit = { jwt => cachedJwt = Some(jwt) } + val reader: () => Option[String] = () => cachedJwt + + val resolver1 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader), + cacheWriter = Some(writer) + ) + resolver1.resolve().licenseType shouldBe LicenseType.Pro + cachedJwt shouldBe Some(proJwt) + + // Second resolve: API key fails, falls back to cache + fetchSucceeds = false + val resolver2 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader), + cacheWriter = Some(writer) + ) + resolver2.resolve().licenseType shouldBe LicenseType.Pro + } + + "LicenseResolver cache writer failure" should "still resolve successfully" in { + val m = manager + val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } + val writer: String => Unit = { _ => throw new RuntimeException("Disk full") } + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheWriter = Some(writer) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } + + "LicenseResolver with real LicenseCache" should "write and delete cache file" in { + val m = manager + val tempDir = Files.createTempDirectory("license-resolver-cache-test") + try { + val cache = new LicenseCache(tempDir.toString) + val proJwt = validProJwt + + // First resolve: API key succeeds -> writes to disk + val fetcher: String => Either[LicenseError, String] = { _ => Right(proJwt) } + val resolver1 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(() => cache.read()), + cacheWriter = Some(jwt => cache.write(jwt)), + cacheInvalidator = Some(() => cache.delete()) + ) + resolver1.resolve().licenseType shouldBe LicenseType.Pro + val cacheFile = tempDir.resolve(LicenseCache.CacheFileName) + Files.exists(cacheFile) shouldBe true + cache.read() shouldBe Some(proJwt) + + // Second resolve: stale cache -> invalidated + val staleJwt = expiredJwt(30) + cache.write(staleJwt) + val failingFetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val resolver2 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(failingFetcher), + cacheReader = Some(() => cache.read()), + cacheWriter = Some(jwt => cache.write(jwt)), + cacheInvalidator = Some(() => cache.delete()) + ) + resolver2.resolve().licenseType shouldBe LicenseType.Community + Files.exists(cacheFile) shouldBe false + } finally { + val stream = Files.list(tempDir) + try { stream.forEach(f => Files.deleteIfExists(f)) } + finally { stream.close() } + Files.deleteIfExists(tempDir) + } + } + + "LicenseResolver cache fallback without API key" should "use cached JWT when static JWT is invalid" in { + val m = manager + val cachedJwt = validProJwt + val tampered = validProJwt.split("\\.")(0) + "." + validProJwt.split("\\.")(1) + "." + + validProJwt.split("\\.")(2).reverse + val reader: () => Option[String] = () => Some(cachedJwt) + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey = Some(tampered)), + jwtLicenseManager = m, + cacheReader = Some(reader) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + } } From acd4ad73c137587d3ab3fe5649b5a92f387a2eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 2 Apr 2026 07:34:03 +0200 Subject: [PATCH 08/11] feat: add expiry grace period and degradation for licensing (Story 5.6) Implement 14-day configurable grace period after JWT expiry with two phases (EarlyGrace/MidGrace), per-request warnings, restoration detection after degradation, and POSIX file permissions for cache files. Closed Issue #67 Co-Authored-By: Claude Opus 4.6 --- build.sbt | 5 +- .../elastic/licensing/ApiKeyClient.scala | 2 +- .../elastic/licensing/JwtLicenseManager.scala | 76 ++++++- .../elastic/licensing/LicenseCache.scala | 24 +- .../elastic/licensing/LicenseResolver.scala | 28 ++- .../elastic/licensing/package.scala | 16 ++ .../elastic/licensing/GraceStatusSpec.scala | 213 ++++++++++++++++++ .../licensing/LicenseResolverSpec.scala | 111 +++++++++ 8 files changed, 463 insertions(+), 12 deletions(-) create mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala diff --git a/build.sbt b/build.sbt index ddab952b..96330862 100644 --- a/build.sbt +++ b/build.sbt @@ -115,7 +115,10 @@ lazy val licensingTestkit = Project(id = "softclient4es-licensing-testkit", base .configs(IntegrationTest) .settings( Defaults.itSettings, - moduleSettings + moduleSettings, + libraryDependencies ++= Seq( + "ch.qos.logback" % "logback-classic" % Versions.logback % Test + ) ) .dependsOn( licensing % "compile->compile" diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala index 1a5f0fbf..d96dd1b4 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala @@ -23,7 +23,7 @@ import com.typesafe.scalalogging.LazyLogging import java.io.IOException import java.net.{HttpURLConnection, SocketTimeoutException, URL} -import scala.collection.JavaConverters._ +import scala.jdk.CollectionConverters._ class ApiKeyClient( baseUrl: String, diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala index d89e3d41..2295b1cc 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala @@ -18,18 +18,26 @@ package app.softnetwork.elastic.licensing import com.nimbusds.jose.jwk.OctetKeyPair import com.nimbusds.jwt.SignedJWT +import com.typesafe.scalalogging.LazyLogging import java.time.{Duration, Instant} import java.util.concurrent.atomic.AtomicReference -import scala.collection.JavaConverters._ +import scala.jdk.CollectionConverters._ import scala.util.Try class JwtLicenseManager( publicKeyOverride: Option[OctetKeyPair] = None, expectedIssuer: String = "https://license.softclient4es.com" -) extends LicenseManager { - - private case class LicenseState(licenseKey: LicenseKey, quota: Quota) +) extends LicenseManager + with LazyLogging { + + private case class LicenseState( + licenseKey: LicenseKey, + quota: Quota, + graceStatus: GraceStatus = GraceStatus.NotInGrace, + gracePeriodDays: Option[Long] = None, + degraded: Boolean = false + ) private val state: AtomicReference[LicenseState] = new AtomicReference( LicenseState(LicenseKey.Community, Quota.Community) @@ -45,7 +53,15 @@ class JwtLicenseManager( doValidate(jwt, gracePeriod = Some(gracePeriod)) def resetToCommunity(): Unit = - state.set(LicenseState(LicenseKey.Community, Quota.Community)) + state.set( + LicenseState( + LicenseKey.Community, + Quota.Community, + GraceStatus.NotInGrace, + None, + degraded = true + ) + ) override def hasFeature(feature: Feature): Boolean = state.get().licenseKey.features.contains(feature) @@ -54,6 +70,31 @@ class JwtLicenseManager( override def licenseType: LicenseType = state.get().licenseKey.licenseType + override def graceStatus: GraceStatus = state.get().graceStatus + + override def wasDegraded: Boolean = state.get().degraded + + // Per-request warning — only fires during MidGrace (second half of grace period). + // EarlyGrace startup warnings are handled by LicenseResolver.logGraceWarning(). + // Re-computes days from Instant.now() so warnings stay accurate between validate() calls. + override def warnIfInGrace(): Unit = { + val s = state.get() + s.licenseKey.expiresAt.foreach { exp => + if (exp.isBefore(Instant.now())) { + s.gracePeriodDays.foreach { gpDays => + val daysSinceExpiry = Duration.between(exp, Instant.now()).toDays + val earlyThreshold = gpDays / 2 + if (daysSinceExpiry >= earlyThreshold && daysSinceExpiry <= gpDays) { + val daysRemaining = math.max(0L, gpDays - daysSinceExpiry) + logger.warn( + s"License expired $daysSinceExpiry days ago. Service will degrade to Community in $daysRemaining days." + ) + } + } + } + } + } + private def doValidate( jwt: String, gracePeriod: Option[Duration] @@ -66,10 +107,33 @@ class JwtLicenseManager( key <- extractLicenseKey(signed, gracePeriod) } yield { val quota = extractQuota(signed) - state.set(LicenseState(key, quota)) + val gs = computeGraceStatus(key.expiresAt, gracePeriod) + val gpDays = gracePeriod.map(_.toDays) + state.set(LicenseState(key, quota, gs, gpDays, degraded = false)) key } + private def computeGraceStatus( + expiresAt: Option[Instant], + gracePeriod: Option[Duration] + ): GraceStatus = + expiresAt match { + case Some(exp) if exp.isBefore(Instant.now()) => + gracePeriod match { + case Some(grace) => + val daysSinceExpiry = Duration.between(exp, Instant.now()).toDays + val graceDays = grace.toDays + val earlyThreshold = graceDays / 2 + if (daysSinceExpiry < earlyThreshold) + GraceStatus.EarlyGrace(daysSinceExpiry) + else + GraceStatus.MidGrace(daysSinceExpiry, math.max(0L, graceDays - daysSinceExpiry)) + case None => + GraceStatus.NotInGrace + } + case _ => GraceStatus.NotInGrace + } + private def parseJwt(jwt: String): Either[LicenseError, SignedJWT] = Try(SignedJWT.parse(jwt)).toEither.left.map(_ => InvalidLicense("Malformed JWT")) diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala index 2df757ea..184865a0 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala @@ -17,6 +17,7 @@ package app.softnetwork.elastic.licensing import java.nio.file.{Files, Paths, StandardCopyOption} +import java.nio.file.attribute.PosixFilePermissions import com.typesafe.scalalogging.LazyLogging @@ -50,10 +51,29 @@ class LicenseCache(cacheDir: String) extends LazyLogging { try { val dir = Paths.get(cacheDir) if (!Files.exists(dir)) { - Files.createDirectories(dir) + try { + Files.createDirectories( + dir, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")) + ) + } catch { + case _: UnsupportedOperationException => + Files.createDirectories(dir) + } } val target = dir.resolve(CacheFileName) - val tmp = Files.createTempFile(dir, ".license-cache", ".tmp") + val tmp = + try { + Files.createTempFile( + dir, + ".license-cache", + ".tmp", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")) + ) + } catch { + case _: UnsupportedOperationException => + Files.createTempFile(dir, ".license-cache", ".tmp") + } try { Files.write(tmp, jwt.getBytes("UTF-8")) try { diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala index a3b151ea..365ffd88 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala @@ -33,8 +33,13 @@ class LicenseResolver( def resolve(): LicenseKey = { // Step 1: Static JWT config.key.foreach { jwt => + val wasDegraded = jwtLicenseManager.wasDegraded jwtLicenseManager.validateWithGracePeriod(jwt, gracePeriod) match { case Right(key) => + if (wasDegraded && key.licenseType.isPaid) { + logger.info(s"License restored to ${key.licenseType}") + } + logGraceWarning(jwtLicenseManager.graceStatus) return key case Left(ExpiredLicense(exp)) => logger.warn(s"Static JWT expired at $exp (beyond ${config.gracePeriod} grace period)") @@ -48,11 +53,16 @@ class LicenseResolver( apiKeyFetcher.foreach { fetcher => fetcher(apiKey) match { case Right(jwt) => + // After degradation, oldTier is Community (from resetToCommunity). + // wasDegraded distinguishes this from a cold start. val oldTier = jwtLicenseManager.licenseType + val wasDegraded = jwtLicenseManager.wasDegraded jwtLicenseManager.validate(jwt) match { case Right(key) => val newTier = key.licenseType - if (newTier != oldTier) { + if (wasDegraded && newTier.isPaid) { + logger.info(s"License restored to $newTier") + } else if (newTier != oldTier) { val direction = if (newTier.ordinal > oldTier.ordinal) "upgraded" else "downgraded" @@ -82,6 +92,7 @@ class LicenseResolver( jwtLicenseManager.validateWithGracePeriod(jwt, gracePeriod) match { case Right(key) => logger.warn("Using cached license") + logGraceWarning(jwtLicenseManager.graceStatus) return key case Left(_) => // Cached JWT is invalid or expired beyond grace — delete it @@ -100,11 +111,24 @@ class LicenseResolver( if (config.apiKey.isDefined) { logger.error("Could not fetch license — falling back to Community mode") } else if (config.key.isDefined) { - logger.error("License invalid or expired — falling back to Community mode") + logger.error("License expired or invalid — running in Community mode.") } else { logger.info("Running in Community mode") } jwtLicenseManager.resetToCommunity() LicenseKey.Community } + + private def logGraceWarning(status: GraceStatus): Unit = status match { + case GraceStatus.EarlyGrace(days) => + logger.warn( + s"License expired $days days ago. Renew at https://portal.softclient4es.com" + ) + case GraceStatus.MidGrace(days, remaining) => + logger.warn( + s"License expired $days days ago. Service will degrade to Community in $remaining days." + ) + case GraceStatus.NotInGrace => + // No warning needed + } } diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index 21a6b9c8..db3d454d 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -135,6 +135,13 @@ package object licensing { ) } + sealed trait GraceStatus + object GraceStatus { + case object NotInGrace extends GraceStatus + case class EarlyGrace(daysExpired: Long) extends GraceStatus + case class MidGrace(daysExpired: Long, daysRemaining: Long) extends GraceStatus + } + trait LicenseManager { /** Validate license key */ @@ -148,6 +155,15 @@ package object licensing { /** Get license type */ def licenseType: LicenseType + + /** Get current grace status */ + def graceStatus: GraceStatus = GraceStatus.NotInGrace + + /** Whether the license was degraded to Community due to expiry/failure */ + def wasDegraded: Boolean = false + + /** Log a warning if the license is in mid-grace period (per-request use) */ + def warnIfInGrace(): Unit = () } sealed trait LicenseError { diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala new file mode 100644 index 00000000..8dbcdf98 --- /dev/null +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala @@ -0,0 +1,213 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import ch.qos.logback.classic.{Level, Logger} +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.slf4j.LoggerFactory + +import java.util.Date +import scala.jdk.CollectionConverters._ + +class GraceStatusSpec extends AnyFlatSpec with Matchers { + + private def manager = new JwtLicenseManager( + publicKeyOverride = Some(JwtTestHelper.publicKey) + ) + + private val gracePeriod14d = java.time.Duration.ofDays(14) + + private def validProJwt: String = + JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) + + private def expiredJwt(daysAgo: Int): String = { + val expDate = new Date(System.currentTimeMillis() - daysAgo.toLong * 24 * 3600000L) + JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) + } + + private def withLogCapture[T](f: ListAppender[ILoggingEvent] => T): T = { + val loggerName = classOf[JwtLicenseManager].getName + val logger = LoggerFactory.getLogger(loggerName).asInstanceOf[Logger] + val appender = new ListAppender[ILoggingEvent]() + appender.start() + logger.addAppender(appender) + try { + f(appender) + } finally { + logger.detachAppender(appender) + appender.stop() + } + } + + // --- GraceStatus computation (AC #1, #2, #5) --- + + "JwtLicenseManager with valid non-expired JWT" should "have NotInGrace status" in { + val m = manager + val jwt = validProJwt + m.validateWithGracePeriod(jwt, gracePeriod14d) + m.graceStatus shouldBe GraceStatus.NotInGrace + } + + "JwtLicenseManager with JWT expired 1 day ago" should "have EarlyGrace status" in { + val m = manager + val jwt = expiredJwt(1) + m.validateWithGracePeriod(jwt, gracePeriod14d) + m.graceStatus shouldBe a[GraceStatus.EarlyGrace] + m.graceStatus.asInstanceOf[GraceStatus.EarlyGrace].daysExpired shouldBe 1L + } + + "JwtLicenseManager with JWT expired 6 days ago" should "have EarlyGrace status" in { + val m = manager + val jwt = expiredJwt(6) + m.validateWithGracePeriod(jwt, gracePeriod14d) + m.graceStatus shouldBe a[GraceStatus.EarlyGrace] + m.graceStatus.asInstanceOf[GraceStatus.EarlyGrace].daysExpired shouldBe 6L + } + + "JwtLicenseManager with JWT expired 7 days ago" should "have MidGrace status" in { + val m = manager + val jwt = expiredJwt(7) + m.validateWithGracePeriod(jwt, gracePeriod14d) + m.graceStatus shouldBe a[GraceStatus.MidGrace] + val mg = m.graceStatus.asInstanceOf[GraceStatus.MidGrace] + mg.daysExpired shouldBe 7L + mg.daysRemaining shouldBe 7L + } + + "JwtLicenseManager with JWT expired 13 days ago" should "have MidGrace status" in { + val m = manager + val jwt = expiredJwt(13) + m.validateWithGracePeriod(jwt, gracePeriod14d) + m.graceStatus shouldBe a[GraceStatus.MidGrace] + val mg = m.graceStatus.asInstanceOf[GraceStatus.MidGrace] + mg.daysExpired shouldBe 13L + mg.daysRemaining shouldBe 1L + } + + "JwtLicenseManager after validate() (no grace)" should "have NotInGrace status" in { + val m = manager + val jwt = validProJwt + m.validate(jwt) + m.graceStatus shouldBe GraceStatus.NotInGrace + } + + "JwtLicenseManager after resetToCommunity" should "have NotInGrace status" in { + val m = manager + val jwt = expiredJwt(7) + m.validateWithGracePeriod(jwt, gracePeriod14d) + m.graceStatus shouldBe a[GraceStatus.MidGrace] + m.resetToCommunity() + m.graceStatus shouldBe GraceStatus.NotInGrace + } + + // --- Custom grace period scaling (AC #5) --- + + "JwtLicenseManager with 30-day grace period" should "have earlyThreshold at 15 days" in { + val m = manager + val jwt = expiredJwt(10) + val gracePeriod30d = java.time.Duration.ofDays(30) + m.validateWithGracePeriod(jwt, gracePeriod30d) + m.graceStatus shouldBe a[GraceStatus.EarlyGrace] // 10 < 15 (30/2) + } + + "JwtLicenseManager with 30-day grace period, expired 20 days" should "have MidGrace" in { + val m = manager + val jwt = expiredJwt(20) + val gracePeriod30d = java.time.Duration.ofDays(30) + m.validateWithGracePeriod(jwt, gracePeriod30d) + m.graceStatus shouldBe a[GraceStatus.MidGrace] + val mg = m.graceStatus.asInstanceOf[GraceStatus.MidGrace] + mg.daysExpired shouldBe 20L + mg.daysRemaining shouldBe 10L + } + + // --- wasDegraded (AC #4) --- + + "JwtLicenseManager.wasDegraded" should "be false initially" in { + val m = manager + m.wasDegraded shouldBe false + } + + "JwtLicenseManager.wasDegraded" should "be true after resetToCommunity" in { + val m = manager + m.resetToCommunity() + m.wasDegraded shouldBe true + } + + "JwtLicenseManager.wasDegraded" should "be false after successful validate" in { + val m = manager + m.resetToCommunity() + m.wasDegraded shouldBe true + m.validate(validProJwt) + m.wasDegraded shouldBe false + } + + "JwtLicenseManager.wasDegraded" should "be false after successful validateWithGracePeriod" in { + val m = manager + m.resetToCommunity() + m.wasDegraded shouldBe true + val jwt = expiredJwt(5) // within grace + m.validateWithGracePeriod(jwt, gracePeriod14d) + m.wasDegraded shouldBe false + } + + // --- warnIfInGrace (AC #2) --- + + private val degradationMessagePattern = "degrade to Community" + + "JwtLicenseManager.warnIfInGrace with non-expired JWT" should "not emit a degradation warning" in { + val m = manager + withLogCapture { appender => + m.validate(validProJwt) + appender.list.clear() + m.warnIfInGrace() + m.graceStatus shouldBe GraceStatus.NotInGrace + val degradationWarnings = appender.list.asScala + .filter(e => e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern)) + degradationWarnings shouldBe empty + } + } + + "JwtLicenseManager.warnIfInGrace with early grace JWT" should "not emit a degradation warning" in { + val m = manager + withLogCapture { appender => + m.validateWithGracePeriod(expiredJwt(3), gracePeriod14d) + appender.list.clear() + m.warnIfInGrace() + m.graceStatus shouldBe a[GraceStatus.EarlyGrace] + val degradationWarnings = appender.list.asScala + .filter(e => e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern)) + degradationWarnings shouldBe empty + } + } + + "JwtLicenseManager.warnIfInGrace with mid-grace JWT" should "emit a degradation warning" in { + val m = manager + withLogCapture { appender => + m.validateWithGracePeriod(expiredJwt(10), gracePeriod14d) + appender.list.clear() + m.warnIfInGrace() + m.graceStatus shouldBe a[GraceStatus.MidGrace] + val degradationWarnings = appender.list.asScala + .filter(e => e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern)) + degradationWarnings should not be empty + } + } +} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala index 00436ece..6c1ebce4 100644 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala @@ -517,6 +517,117 @@ class LicenseResolverSpec extends AnyFlatSpec with Matchers { } } + // --- Grace status at startup (AC #1, #2, #3) --- + + "LicenseResolver with early grace static JWT" should "resolve with full access" in { + val m = manager + val jwt = expiredJwt(3) // 3 days ago, grace = 14d, earlyThreshold = 7d + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey = Some(jwt)), + jwtLicenseManager = m + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + m.graceStatus shouldBe a[GraceStatus.EarlyGrace] + } + + "LicenseResolver with mid-grace static JWT" should "resolve with full access" in { + val m = manager + val jwt = expiredJwt(10) // 10 days ago, grace = 14d + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey = Some(jwt)), + jwtLicenseManager = m + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + m.graceStatus shouldBe a[GraceStatus.MidGrace] + } + + "LicenseResolver with beyond-grace static JWT and no API key" should "degrade to Community" in { + val m = manager + val jwt = expiredJwt(30) // 30 days ago, grace = 14d + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey = Some(jwt)), + jwtLicenseManager = m + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Community + m.wasDegraded shouldBe true + } + + "LicenseResolver with mid-grace cached JWT" should "resolve with full access and correct grace status" in { + val m = manager + val jwt = expiredJwt(10) + val fetcher: String => Either[LicenseError, String] = { _ => + Left(InvalidLicense("Network error")) + } + val reader: () => Option[String] = () => Some(jwt) + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher), + cacheReader = Some(reader) + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + m.graceStatus shouldBe a[GraceStatus.MidGrace] + } + + // --- Restoration detection (AC #4) --- + + "LicenseResolver restoration after degradation" should "log restored when renewed via API key" in { + val m = manager + // Simulate degradation: resolve with expired JWT, no API key + val expJwt = expiredJwt(30) + val resolver1 = new LicenseResolver( + config = defaultConfig(licenseKey = Some(expJwt)), + jwtLicenseManager = m + ) + resolver1.resolve().licenseType shouldBe LicenseType.Community + m.wasDegraded shouldBe true + + // Restore via API key + val freshJwt = validProJwt + val fetcher: String => Either[LicenseError, String] = { _ => Right(freshJwt) } + val resolver2 = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + val key = resolver2.resolve() + key.licenseType shouldBe LicenseType.Pro + m.wasDegraded shouldBe false + } + + "LicenseResolver restoration after degradation" should "log restored when renewed via static JWT" in { + val m = manager + // Degrade + m.resetToCommunity() + m.wasDegraded shouldBe true + + // Restore via static JWT + val resolver = new LicenseResolver( + config = defaultConfig(licenseKey = Some(validProJwt)), + jwtLicenseManager = m + ) + val key = resolver.resolve() + key.licenseType shouldBe LicenseType.Pro + m.wasDegraded shouldBe false + } + + "LicenseResolver cold start" should "not report restoration" in { + val m = manager + m.wasDegraded shouldBe false + val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } + val resolver = new LicenseResolver( + config = defaultConfig(apiKey = Some("sk-test")), + jwtLicenseManager = m, + apiKeyFetcher = Some(fetcher) + ) + resolver.resolve().licenseType shouldBe LicenseType.Pro + m.wasDegraded shouldBe false + } + "LicenseResolver cache fallback without API key" should "use cached JWT when static JWT is invalid" in { val m = manager val cachedJwt = validProJwt From 64e48336d02c2f938ef5ad0aa614097cf577bc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 2 Apr 2026 07:34:56 +0200 Subject: [PATCH 09/11] refactor: improve formatting of warning message filters in GraceStatusSpec --- .../elastic/licensing/GraceStatusSpec.scala | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala index 8dbcdf98..7355d63d 100644 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala +++ b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala @@ -180,7 +180,9 @@ class GraceStatusSpec extends AnyFlatSpec with Matchers { m.warnIfInGrace() m.graceStatus shouldBe GraceStatus.NotInGrace val degradationWarnings = appender.list.asScala - .filter(e => e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern)) + .filter(e => + e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern) + ) degradationWarnings shouldBe empty } } @@ -193,7 +195,9 @@ class GraceStatusSpec extends AnyFlatSpec with Matchers { m.warnIfInGrace() m.graceStatus shouldBe a[GraceStatus.EarlyGrace] val degradationWarnings = appender.list.asScala - .filter(e => e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern)) + .filter(e => + e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern) + ) degradationWarnings shouldBe empty } } @@ -206,7 +210,9 @@ class GraceStatusSpec extends AnyFlatSpec with Matchers { m.warnIfInGrace() m.graceStatus shouldBe a[GraceStatus.MidGrace] val degradationWarnings = appender.list.asScala - .filter(e => e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern)) + .filter(e => + e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern) + ) degradationWarnings should not be empty } } From f345f21db6823f76ab8c7a486a6a87a3821909ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Fri, 3 Apr 2026 08:46:11 +0200 Subject: [PATCH 10/11] fix: update comment to clarify materialized views handling in CoreDdlExtension --- .../elastic/client/extensions/CoreDdlExtension.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala b/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala index 33a59092..2241f999 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala @@ -31,7 +31,7 @@ import scala.concurrent.{ExecutionContext, Future} * {{{ * ✅ OSS (always loaded) * ✅ Handles simple DDL (CREATE TABLE, DROP TABLE, etc.) - * ✅ Does NOT handle materialized views (premium extension) + * ✅ Does NOT handle materialized views (community extension) * }}} */ //format:on From 4e796435acd448aa41d4a25d03e2f062078582ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Sun, 5 Apr 2026 08:03:37 +0200 Subject: [PATCH 11/11] feat: introduce LicenseManagerSpi and move JWT licensing to extensions Add ServiceLoader-based SPI architecture for pluggable license managers. Core licensing module is now lightweight (no crypto deps). JWT implementation moved to softclient4es-extensions. CommunityLicenseManager is the fallback when no extensions JAR is present. Key changes: - LicenseManagerSpi trait with priority-based discovery - LicenseMode (LongRunning/Driver) for runtime context - CommunityLicenseManager replaces DefaultLicenseManager (deprecated alias kept) - ExtensionApi uses lazy val with SPI resolution and error fallback - refresh() method on LicenseManager trait (RefreshNotSupported for Community) - licensingTestkit subproject removed (moved to extensions) Closed Issue #71 Co-Authored-By: Claude Opus 4.6 (1M context) --- build.sbt | 14 - .../elastic/client/ExtensionApi.scala | 48 +- licensing/build.sbt | 5 - ...etwork.elastic.licensing.LicenseManagerSpi | 1 + licensing/src/main/resources/keys/README.md | 23 - .../elastic/licensing/ApiKeyClient.scala | 129 ---- .../licensing/CommunityLicenseManager.scala | 40 ++ .../CommunityLicenseManagerSpi.scala | 31 + .../licensing/DefaultLicenseManager.scala | 74 -- .../elastic/licensing/InstanceId.scala | 48 -- .../elastic/licensing/JwtLicenseManager.scala | 236 ------- .../elastic/licensing/LicenseCache.scala | 119 ---- .../licensing/LicenseKeyVerifier.scala | 67 -- .../elastic/licensing/LicenseManagerSpi.scala | 69 ++ .../elastic/licensing/LicenseResolver.scala | 134 ---- .../elastic/licensing/package.scala | 14 + .../elastic/licensing/ApiKeyClientSpec.scala | 273 -------- .../elastic/licensing/InstanceIdSpec.scala | 87 --- .../elastic/licensing/LicenseCacheSpec.scala | 124 ---- .../licensing/LicenseManagerSpec.scala | 101 +-- .../licensing/LicenseManagerSpiSpec.scala | 132 ++++ licensing/testkit/build.sbt | 5 - .../resources/keys/softclient4es-test.jwk | 1 - .../elastic/licensing/JwtTestHelper.scala | 133 ---- .../elastic/licensing/GraceStatusSpec.scala | 219 ------ .../licensing/JwtLicenseManagerSpec.scala | 306 --------- .../licensing/LicenseKeyVerifierSpec.scala | 76 --- .../licensing/LicenseResolverSpec.scala | 645 ------------------ project/Versions.scala | 6 - 29 files changed, 368 insertions(+), 2792 deletions(-) create mode 100644 licensing/src/main/resources/META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi delete mode 100644 licensing/src/main/resources/keys/README.md delete mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManager.scala create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala delete mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala delete mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala delete mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala delete mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala delete mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala create mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala delete mode 100644 licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala delete mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala delete mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala delete mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala create mode 100644 licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala delete mode 100644 licensing/testkit/build.sbt delete mode 100644 licensing/testkit/src/main/resources/keys/softclient4es-test.jwk delete mode 100644 licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala delete mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala delete mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala delete mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala delete mode 100644 licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala diff --git a/build.sbt b/build.sbt index 96330862..e88ae69e 100644 --- a/build.sbt +++ b/build.sbt @@ -111,19 +111,6 @@ lazy val licensing = project buildInfoObject := "LicensingBuildInfo" ) -lazy val licensingTestkit = Project(id = "softclient4es-licensing-testkit", base = file("licensing/testkit")) - .configs(IntegrationTest) - .settings( - Defaults.itSettings, - moduleSettings, - libraryDependencies ++= Seq( - "ch.qos.logback" % "logback-classic" % Versions.logback % Test - ) - ) - .dependsOn( - licensing % "compile->compile" - ) - lazy val sql = project .in(file("sql")) .configs(IntegrationTest) @@ -586,7 +573,6 @@ lazy val root = project ) .aggregate( licensing, - licensingTestkit, sql, bridge, macros, diff --git a/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala b/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala index 56b857e9..27432ecf 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala @@ -16,13 +16,53 @@ package app.softnetwork.elastic.client -import app.softnetwork.elastic.licensing.{DefaultLicenseManager, LicenseManager} +import java.util.ServiceLoader + +import scala.jdk.CollectionConverters._ + +import app.softnetwork.elastic.licensing.{ + CommunityLicenseManager, + LicenseManager, + LicenseManagerSpi, + LicenseMode +} trait ExtensionApi { self: ElasticClientApi => - // ✅ Inject license manager (overridable) - def licenseManager: LicenseManager = new DefaultLicenseManager() + /** Runtime context for licensing behavior. Override in concrete implementations: + * - REPL/CLI: `Some(LicenseMode.LongRunning)` — auto-refresh + * - JDBC/ADBC: `Some(LicenseMode.Driver)` — on-demand, no implicit calls + * - Default: `None` — safe default (Driver semantics) + */ + def licenseMode: Option[LicenseMode] = None + + /** License manager resolved via SPI (highest-priority LicenseManagerSpi wins). The licenseMode is + * passed to the SPI to wire the appropriate refresh strategy. Falls back to + * CommunityLicenseManager if no SPI implementation is found or if the winning SPI fails to + * create a manager. + * + * CONSTRAINT: SPI create() must NOT reference this ElasticClientApi instance. + */ + lazy val licenseManager: LicenseManager = { + val loader = ServiceLoader.load(classOf[LicenseManagerSpi]) + val spis = loader.iterator().asScala.toSeq.sortBy(_.priority) + spis.headOption + .flatMap { spi => + try { + Some(spi.create(config, licenseMode)) + } catch { + case e: Exception => + logger.error( + s"Failed to create LicenseManager from ${spi.getClass.getName}: ${e.getMessage}", + e + ) + None + } + } + .getOrElse(new CommunityLicenseManager()) + } /** Extension registry (lazy loaded) */ - lazy val extensionRegistry: ExtensionRegistry = new ExtensionRegistry(config, licenseManager) + lazy val extensionRegistry: ExtensionRegistry = + new ExtensionRegistry(config, licenseManager) } diff --git a/licensing/build.sbt b/licensing/build.sbt index c66efb21..6cfd4f95 100644 --- a/licensing/build.sbt +++ b/licensing/build.sbt @@ -3,12 +3,7 @@ organization := "app.softnetwork.elastic" name := "softclient4es-licensing" libraryDependencies ++= Seq( - "com.nimbusds" % "nimbus-jose-jwt" % Versions.nimbusJoseJwt, - "org.bouncycastle" % "bcprov-jdk18on" % Versions.bouncyCastle, - "com.google.crypto.tink" % "tink" % Versions.tink, "com.typesafe" % "config" % Versions.typesafeConfig, "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging, - "com.fasterxml.jackson.core" % "jackson-databind" % Versions.jackson, "org.scalatest" %% "scalatest" % Versions.scalatest % Test ) - diff --git a/licensing/src/main/resources/META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi b/licensing/src/main/resources/META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi new file mode 100644 index 00000000..5f701f17 --- /dev/null +++ b/licensing/src/main/resources/META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi @@ -0,0 +1 @@ +app.softnetwork.elastic.licensing.CommunityLicenseManagerSpi diff --git a/licensing/src/main/resources/keys/README.md b/licensing/src/main/resources/keys/README.md deleted file mode 100644 index c9e5292e..00000000 --- a/licensing/src/main/resources/keys/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# License Public Keys - -Ed25519 public keys in JWK JSON format (`.jwk` files). - -## Format - -```json -{ - "kty": "OKP", - "crv": "Ed25519", - "x": "", - "kid": "" -} -``` - -## Key Naming - -Files are named `{kid}.jwk` where `kid` matches the JWT `kid` header. -Example: `softclient4es-2026-03.jwk` - -## Adding Keys - -Production keys are generated by the license server and added here during release. diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala deleted file mode 100644 index d96dd1b4..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/ApiKeyClient.scala +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import app.softnetwork.elastic.LicensingBuildInfo -import com.fasterxml.jackson.databind.ObjectMapper -import com.typesafe.scalalogging.LazyLogging - -import java.io.IOException -import java.net.{HttpURLConnection, SocketTimeoutException, URL} - -import scala.jdk.CollectionConverters._ - -class ApiKeyClient( - baseUrl: String, - instanceId: String, - connectTimeoutMs: Int = 10000, - readTimeoutMs: Int = 10000 -) extends LazyLogging { - - import ApiKeyClient._ - - def fetchJwt(apiKey: String): Either[LicenseError, String] = { - // Step 1: Validate API key format - if (!apiKey.startsWith("sk-") || apiKey.length <= 3) { - return Left(InvalidLicense("Invalid API key format")) - } - - try { - // Step 2: Build request body - val body = mapper.writeValueAsString( - Map("instance_id" -> instanceId, "version" -> LicensingBuildInfo.version).asJava - ) - - // Step 3: HTTP POST - val url = new URL(baseUrl + TokenPath) - val conn = url.openConnection().asInstanceOf[HttpURLConnection] - try { - conn.setRequestMethod("POST") - conn.setRequestProperty("Authorization", s"Bearer $apiKey") - conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", "application/json") - conn.setConnectTimeout(connectTimeoutMs) - conn.setReadTimeout(readTimeoutMs) - conn.setInstanceFollowRedirects(false) - conn.setDoOutput(true) - - val bodyBytes = body.getBytes("UTF-8") - val os = conn.getOutputStream - try { - os.write(bodyBytes) - } finally { - os.close() - } - - // Step 4: Read response - val code = conn.getResponseCode - code match { - case 200 => - val responseBody = readStream(conn.getInputStream) - val tree = mapper.readTree(responseBody) - val jwtNode = tree.get("jwt") - if (jwtNode == null || jwtNode.isNull || !jwtNode.isTextual) { - Left(InvalidLicense("Missing jwt in response")) - } else { - val messageNode = tree.get("message") - if (messageNode != null && !messageNode.isNull && messageNode.isTextual) { - logger.info(messageNode.asText()) - } - Right(jwtNode.asText()) - } - - case 401 => - val errorBody = readStream(conn.getErrorStream) - val message = - try { - val tree = mapper.readTree(errorBody) - val msgNode = tree.get("message") - if (msgNode != null && msgNode.isTextual) msgNode.asText() - else "API key rejected (HTTP 401)" - } catch { - case _: Exception => "API key rejected (HTTP 401)" - } - logger.error(message) - Left(InvalidLicense(message)) - - case other => - Left(InvalidLicense(s"Unexpected HTTP status: $other")) - } - } finally { - conn.disconnect() - } - } catch { - case e @ (_: SocketTimeoutException | _: IOException) => - logger.warn(s"Network error fetching license: ${e.getMessage}") - Left(InvalidLicense(s"Network error: ${e.getMessage}")) - } - } - - private def readStream(is: java.io.InputStream): String = { - if (is == null) return "" - val source = scala.io.Source.fromInputStream(is, "UTF-8") - try { - source.mkString - } finally { - source.close() - is.close() - } - } -} - -object ApiKeyClient { - private[licensing] val TokenPath: String = "/api/v1/licenses/token" - private val mapper: ObjectMapper = new ObjectMapper() -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManager.scala new file mode 100644 index 00000000..c4a2c359 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManager.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.typesafe.scalalogging.LazyLogging + +/** Community-tier license manager. No crypto dependencies, no key validation. Always returns + * Community features and quotas. Used as the fallback when no extensions JAR is on the classpath. + */ +class CommunityLicenseManager extends LicenseManager with LazyLogging { + + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("Community mode — license key validation requires the extensions JAR")) + + override def hasFeature(feature: Feature): Boolean = + LicenseKey.Community.features.contains(feature) + + override def quotas: Quota = Quota.Community + + override def licenseType: LicenseType = LicenseType.Community + + override def refresh(): Either[LicenseError, LicenseKey] = { + logger.debug("Community mode — refresh not supported") + Left(RefreshNotSupported) + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala new file mode 100644 index 00000000..a824c683 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.typesafe.config.Config + +/** Fallback SPI that provides Community-tier licensing with no external dependencies. Priority + * Int.MaxValue ensures any other SPI implementation takes precedence. Ignores licenseMode — + * Community is always Community regardless of runtime context. + */ +class CommunityLicenseManagerSpi extends LicenseManagerSpi { + override def priority: Int = Int.MaxValue + override def create( + config: Config, + mode: Option[LicenseMode] = None + ): LicenseManager = new CommunityLicenseManager() +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala deleted file mode 100644 index 21bcb583..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -class DefaultLicenseManager extends LicenseManager { - - private var currentLicense: LicenseKey = LicenseKey.Community - - override def validate(key: String): Either[LicenseError, LicenseKey] = { - key match { - case k if k.startsWith("PRO-") => - val license = LicenseKey( - id = k, - licenseType = LicenseType.Pro, - features = Set( - Feature.MaterializedViews, - Feature.JdbcDriver, - Feature.UnlimitedResults, - Feature.FlightSql - ), - expiresAt = None - ) - currentLicense = license - Right(license) - - case k if k.startsWith("ENT-") => - val license = LicenseKey( - id = k, - licenseType = LicenseType.Enterprise, - features = Set( - Feature.MaterializedViews, - Feature.JdbcDriver, - Feature.OdbcDriver, - Feature.UnlimitedResults, - Feature.AdvancedAggregations, - Feature.FlightSql, - Feature.Federation - ), - expiresAt = None - ) - currentLicense = license - Right(license) - - case _ => - Left(InvalidLicense("Invalid license key format")) - } - } - - override def hasFeature(feature: Feature): Boolean = { - currentLicense.features.contains(feature) - } - - override def quotas: Quota = currentLicense.licenseType match { - case LicenseType.Community => Quota.Community - case LicenseType.Pro => Quota.Pro - case LicenseType.Enterprise => Quota.Enterprise - } - - override def licenseType: LicenseType = currentLicense.licenseType -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala deleted file mode 100644 index 5a17fc4c..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/InstanceId.scala +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.typesafe.scalalogging.LazyLogging - -import java.nio.file.{Files, Path, Paths} -import java.util.UUID - -object InstanceId extends LazyLogging { - - def getOrCreate(cacheDir: String): String = { - try { - val dir = Paths.get(cacheDir) - if (!Files.exists(dir)) { - Files.createDirectories(dir) - } - val file: Path = dir.resolve("instance-id") - if (Files.exists(file)) { - val content = new String(Files.readAllBytes(file), "UTF-8").trim - if (content.nonEmpty) { - return content - } - } - val id = UUID.randomUUID().toString - Files.write(file, id.getBytes("UTF-8")) - id - } catch { - case e: Exception => - logger.warn(s"Could not persist instance ID: ${e.getMessage}") - UUID.randomUUID().toString - } - } -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala deleted file mode 100644 index 2295b1cc..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/JwtLicenseManager.scala +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.nimbusds.jose.jwk.OctetKeyPair -import com.nimbusds.jwt.SignedJWT -import com.typesafe.scalalogging.LazyLogging - -import java.time.{Duration, Instant} -import java.util.concurrent.atomic.AtomicReference -import scala.jdk.CollectionConverters._ -import scala.util.Try - -class JwtLicenseManager( - publicKeyOverride: Option[OctetKeyPair] = None, - expectedIssuer: String = "https://license.softclient4es.com" -) extends LicenseManager - with LazyLogging { - - private case class LicenseState( - licenseKey: LicenseKey, - quota: Quota, - graceStatus: GraceStatus = GraceStatus.NotInGrace, - gracePeriodDays: Option[Long] = None, - degraded: Boolean = false - ) - - private val state: AtomicReference[LicenseState] = new AtomicReference( - LicenseState(LicenseKey.Community, Quota.Community) - ) - - override def validate(jwt: String): Either[LicenseError, LicenseKey] = - doValidate(jwt, gracePeriod = None) - - def validateWithGracePeriod( - jwt: String, - gracePeriod: Duration - ): Either[LicenseError, LicenseKey] = - doValidate(jwt, gracePeriod = Some(gracePeriod)) - - def resetToCommunity(): Unit = - state.set( - LicenseState( - LicenseKey.Community, - Quota.Community, - GraceStatus.NotInGrace, - None, - degraded = true - ) - ) - - override def hasFeature(feature: Feature): Boolean = - state.get().licenseKey.features.contains(feature) - - override def quotas: Quota = state.get().quota - - override def licenseType: LicenseType = state.get().licenseKey.licenseType - - override def graceStatus: GraceStatus = state.get().graceStatus - - override def wasDegraded: Boolean = state.get().degraded - - // Per-request warning — only fires during MidGrace (second half of grace period). - // EarlyGrace startup warnings are handled by LicenseResolver.logGraceWarning(). - // Re-computes days from Instant.now() so warnings stay accurate between validate() calls. - override def warnIfInGrace(): Unit = { - val s = state.get() - s.licenseKey.expiresAt.foreach { exp => - if (exp.isBefore(Instant.now())) { - s.gracePeriodDays.foreach { gpDays => - val daysSinceExpiry = Duration.between(exp, Instant.now()).toDays - val earlyThreshold = gpDays / 2 - if (daysSinceExpiry >= earlyThreshold && daysSinceExpiry <= gpDays) { - val daysRemaining = math.max(0L, gpDays - daysSinceExpiry) - logger.warn( - s"License expired $daysSinceExpiry days ago. Service will degrade to Community in $daysRemaining days." - ) - } - } - } - } - } - - private def doValidate( - jwt: String, - gracePeriod: Option[Duration] - ): Either[LicenseError, LicenseKey] = - for { - signed <- parseJwt(jwt) - publicKey <- resolvePublicKey(signed) - _ <- verifySignature(signed, publicKey) - _ <- validateIssuer(signed) - key <- extractLicenseKey(signed, gracePeriod) - } yield { - val quota = extractQuota(signed) - val gs = computeGraceStatus(key.expiresAt, gracePeriod) - val gpDays = gracePeriod.map(_.toDays) - state.set(LicenseState(key, quota, gs, gpDays, degraded = false)) - key - } - - private def computeGraceStatus( - expiresAt: Option[Instant], - gracePeriod: Option[Duration] - ): GraceStatus = - expiresAt match { - case Some(exp) if exp.isBefore(Instant.now()) => - gracePeriod match { - case Some(grace) => - val daysSinceExpiry = Duration.between(exp, Instant.now()).toDays - val graceDays = grace.toDays - val earlyThreshold = graceDays / 2 - if (daysSinceExpiry < earlyThreshold) - GraceStatus.EarlyGrace(daysSinceExpiry) - else - GraceStatus.MidGrace(daysSinceExpiry, math.max(0L, graceDays - daysSinceExpiry)) - case None => - GraceStatus.NotInGrace - } - case _ => GraceStatus.NotInGrace - } - - private def parseJwt(jwt: String): Either[LicenseError, SignedJWT] = - Try(SignedJWT.parse(jwt)).toEither.left.map(_ => InvalidLicense("Malformed JWT")) - - private def resolvePublicKey(signed: SignedJWT): Either[LicenseError, OctetKeyPair] = - publicKeyOverride match { - case Some(key) => Right(key) - case None => - Option(signed.getHeader.getKeyID) match { - case Some(kid) => LicenseKeyVerifier.loadPublicKey(kid) - case None => Left(InvalidLicense("Missing key ID (kid) in JWT header")) - } - } - - private def verifySignature( - signed: SignedJWT, - publicKey: OctetKeyPair - ): Either[LicenseError, Unit] = - if (LicenseKeyVerifier.verify(signed, publicKey)) Right(()) - else Left(InvalidLicense("Invalid signature")) - - private def validateIssuer(signed: SignedJWT): Either[LicenseError, Unit] = { - val iss = Option(signed.getJWTClaimsSet.getIssuer).getOrElse("") - if (iss == expectedIssuer) Right(()) - else Left(InvalidLicense(s"Invalid issuer: $iss")) - } - - private def extractLicenseKey( - signed: SignedJWT, - gracePeriod: Option[Duration] - ): Either[LicenseError, LicenseKey] = { - val claims = signed.getJWTClaimsSet - - val tierStr = Option(claims.getStringClaim("tier")) - val tier = tierStr.map(LicenseType.fromString).getOrElse(LicenseType.Community) - - val features: Set[Feature] = Option(claims.getStringListClaim("features")) - .map(_.asScala.flatMap(Feature.fromString).toSet) - .getOrElse(Set.empty) - - val expiresAt = Option(claims.getExpirationTime).map(_.toInstant) - - // Check expiry with optional grace period - expiresAt match { - case Some(exp: Instant) if exp.isBefore(Instant.now()) => - gracePeriod match { - case Some(grace) if exp.plus(grace).isAfter(Instant.now()) => - // Within grace period — allow - buildLicenseKey(claims, tier, features, expiresAt) - case _ => - Left(ExpiredLicense(exp)) - } - case _ => - buildLicenseKey(claims, tier, features, expiresAt) - } - } - - private def buildLicenseKey( - claims: com.nimbusds.jwt.JWTClaimsSet, - tier: LicenseType, - features: Set[Feature], - expiresAt: Option[Instant] - ): Either[LicenseError, LicenseKey] = { - val sub = Option(claims.getSubject).getOrElse("unknown") - - val metadata = Map.newBuilder[String, String] - Option(claims.getStringClaim("org_name")).foreach(v => metadata += ("org_name" -> v)) - Option(claims.getJWTID).foreach(v => metadata += ("jti" -> v)) - Try(Option(claims.getBooleanClaim("trial"))) - .getOrElse(None) - .foreach(v => metadata += ("trial" -> v.toString)) - - Right( - LicenseKey( - id = sub, - licenseType = tier, - features = features, - expiresAt = expiresAt, - metadata = metadata.result() - ) - ) - } - - private def extractQuota(signed: SignedJWT): Quota = { - val claims = signed.getJWTClaimsSet - val quotaObj = Option(claims.getJSONObjectClaim("quotas")) - - def intClaim(key: String): Option[Int] = - quotaObj.flatMap(q => Option(q.get(key))).flatMap { - case n: java.lang.Number => Some(n.intValue()) - case _ => None - } - - Quota( - maxMaterializedViews = intClaim("max_materialized_views"), - maxQueryResults = intClaim("max_result_rows"), - maxConcurrentQueries = intClaim("max_concurrent_queries"), - maxClusters = intClaim("max_clusters") - ) - } -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala deleted file mode 100644 index 184865a0..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseCache.scala +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import java.nio.file.{Files, Paths, StandardCopyOption} -import java.nio.file.attribute.PosixFilePermissions - -import com.typesafe.scalalogging.LazyLogging - -class LicenseCache(cacheDir: String) extends LazyLogging { - - import LicenseCache._ - - def read(): Option[String] = { - try { - val file = Paths.get(cacheDir).resolve(CacheFileName) - if (!Files.exists(file)) return None - if (Files.isSymbolicLink(file)) { - logger.warn("License cache file is a symbolic link, ignoring") - return None - } - val size = Files.size(file) - if (size > MaxCacheFileSizeBytes) { - logger.warn(s"License cache file is unexpectedly large ($size bytes), ignoring") - return None - } - val content = new String(Files.readAllBytes(file), "UTF-8").trim - if (content.isEmpty) None else Some(content) - } catch { - case e: Exception => - logger.warn(s"Could not read license cache: ${e.getMessage}") - None - } - } - - def write(jwt: String): Unit = { - try { - val dir = Paths.get(cacheDir) - if (!Files.exists(dir)) { - try { - Files.createDirectories( - dir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")) - ) - } catch { - case _: UnsupportedOperationException => - Files.createDirectories(dir) - } - } - val target = dir.resolve(CacheFileName) - val tmp = - try { - Files.createTempFile( - dir, - ".license-cache", - ".tmp", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")) - ) - } catch { - case _: UnsupportedOperationException => - Files.createTempFile(dir, ".license-cache", ".tmp") - } - try { - Files.write(tmp, jwt.getBytes("UTF-8")) - try { - Files.move( - tmp, - target, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE - ) - } catch { - case _: java.nio.file.AtomicMoveNotSupportedException => - logger.warn( - "Filesystem does not support atomic move — using non-atomic overwrite" - ) - Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING) - } - } catch { - case e: Exception => - try { Files.deleteIfExists(tmp) } - catch { case _: Exception => } - throw e - } - } catch { - case e: Exception => - logger.warn(s"Could not write license cache: ${e.getMessage}") - } - } - - def delete(): Unit = { - try { - val file = Paths.get(cacheDir).resolve(CacheFileName) - Files.deleteIfExists(file) - } catch { - case e: Exception => - logger.warn(s"Could not delete license cache: ${e.getMessage}") - } - } -} - -object LicenseCache { - private[licensing] val CacheFileName: String = "license-cache.jwt" - private val MaxCacheFileSizeBytes: Long = 65536L -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala deleted file mode 100644 index a50a85ca..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifier.scala +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.nimbusds.jose.crypto.Ed25519Verifier -import com.nimbusds.jose.jwk.OctetKeyPair -import com.nimbusds.jwt.SignedJWT - -import scala.io.Source -import scala.util.{Failure, Success, Try} - -object LicenseKeyVerifier { - - /** Verify a signed JWT against an Ed25519 public key. - * - * @param jws - * the signed JWT to verify - * @param publicKey - * the Ed25519 public key (JWK format) - * @return - * true if the signature is valid - */ - def verify(jws: SignedJWT, publicKey: OctetKeyPair): Boolean = - Try(jws.verify(new Ed25519Verifier(publicKey.toPublicJWK))).getOrElse(false) - - /** Load an Ed25519 public key from the classpath by key ID. - * - * Looks for `keys/{kid}.jwk` on the classpath and parses it as JWK JSON. - * - * @param kid - * the key ID (matches the JWT `kid` header) - * @return - * the public key or a LicenseError - */ - def loadPublicKey(kid: String): Either[LicenseError, OctetKeyPair] = { - val resourcePath = s"keys/$kid.jwk" - Option(getClass.getClassLoader.getResourceAsStream(resourcePath)) match { - case None => - Left(InvalidLicense(s"Unknown key ID: $kid")) - case Some(is) => - val result = Try { - val json = Source.fromInputStream(is, "UTF-8").mkString - OctetKeyPair.parse(json).toPublicJWK.asInstanceOf[OctetKeyPair] - } - is.close() - result match { - case Success(key) => Right(key) - case Failure(ex) => - Left(InvalidLicense(s"Failed to parse key '$kid': ${ex.getMessage}")) - } - } - } -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala new file mode 100644 index 00000000..0156ef65 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import com.typesafe.config.Config + +/** Runtime context that determines licensing behavior. + * + * The LicenseManagerSpi uses this to wire the appropriate refresh strategy: + * - LongRunning: AutoRefreshStrategy (periodic background refresh) + * - Driver: OnDemandRefreshStrategy (single fetch, checkExpiry on access, no implicit calls) + */ +sealed trait LicenseMode + +object LicenseMode { + + /** Long-running process: REPL, Arrow Flight SQL server, Federation server. License refreshes + * automatically in the background. + */ + case object LongRunning extends LicenseMode + + /** Short-lived driver session: JDBC, ADBC. License fetched once at connect time, never refreshed + * implicitly. Expired JWTs silently fall back to Community tier via checkExpiry(). + */ + case object Driver extends LicenseMode +} + +/** Service Provider Interface for pluggable license manager implementations. + * + * Discovered via `java.util.ServiceLoader`. The implementation with the lowest priority value + * wins. + * + * Register implementations in: + * `META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi` + */ +trait LicenseManagerSpi { + + /** Priority for SPI resolution (lower = higher priority). Default: 100. + * CommunityLicenseManagerSpi uses Int.MaxValue (lowest priority). + */ + def priority: Int = 100 + + /** Create a LicenseManager from application configuration. + * + * @param config + * The application configuration (typically from `ConfigFactory.load()`) + * @param mode + * Runtime context hint. `None` = unknown context (safe default: Driver semantics). + * `Some(LongRunning)` = wire AutoRefreshStrategy. `Some(Driver)` = wire + * OnDemandRefreshStrategy with checkExpiry(). + * @return + * A configured LicenseManager instance with appropriate refresh behavior + */ + def create(config: Config, mode: Option[LicenseMode] = None): LicenseManager +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala deleted file mode 100644 index 365ffd88..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseResolver.scala +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.typesafe.scalalogging.LazyLogging - -class LicenseResolver( - config: LicenseConfig, - jwtLicenseManager: JwtLicenseManager, - apiKeyFetcher: Option[String => Either[LicenseError, String]] = None, - cacheReader: Option[() => Option[String]] = None, - cacheWriter: Option[String => Unit] = None, - cacheInvalidator: Option[() => Unit] = None -) extends LazyLogging { - - private val gracePeriod: java.time.Duration = - java.time.Duration.ofMillis(config.gracePeriod.toMillis) - - def resolve(): LicenseKey = { - // Step 1: Static JWT - config.key.foreach { jwt => - val wasDegraded = jwtLicenseManager.wasDegraded - jwtLicenseManager.validateWithGracePeriod(jwt, gracePeriod) match { - case Right(key) => - if (wasDegraded && key.licenseType.isPaid) { - logger.info(s"License restored to ${key.licenseType}") - } - logGraceWarning(jwtLicenseManager.graceStatus) - return key - case Left(ExpiredLicense(exp)) => - logger.warn(s"Static JWT expired at $exp (beyond ${config.gracePeriod} grace period)") - case Left(err) => - logger.error(s"Static JWT invalid: ${err.message}") - } - } - - // Step 2: API key fetch - config.apiKey.foreach { apiKey => - apiKeyFetcher.foreach { fetcher => - fetcher(apiKey) match { - case Right(jwt) => - // After degradation, oldTier is Community (from resetToCommunity). - // wasDegraded distinguishes this from a cold start. - val oldTier = jwtLicenseManager.licenseType - val wasDegraded = jwtLicenseManager.wasDegraded - jwtLicenseManager.validate(jwt) match { - case Right(key) => - val newTier = key.licenseType - if (wasDegraded && newTier.isPaid) { - logger.info(s"License restored to $newTier") - } else if (newTier != oldTier) { - val direction = - if (newTier.ordinal > oldTier.ordinal) "upgraded" - else "downgraded" - logger.info(s"License $direction from $oldTier to $newTier") - } - // Write to disk cache for offline fallback - cacheWriter.foreach { writer => - try { writer(jwt) } - catch { - case e: Exception => - logger.warn(s"Could not write license to cache: ${e.getMessage}") - } - } - return key - case Left(err) => - logger.error(s"Fetched JWT is invalid: ${err.message}") - } - case Left(err) => - logger.warn(s"Failed to fetch license: ${err.message}") - } - } - } - - // Step 3: Disk cache - cacheReader.foreach { reader => - reader().foreach { jwt => - jwtLicenseManager.validateWithGracePeriod(jwt, gracePeriod) match { - case Right(key) => - logger.warn("Using cached license") - logGraceWarning(jwtLicenseManager.graceStatus) - return key - case Left(_) => - // Cached JWT is invalid or expired beyond grace — delete it - cacheInvalidator.foreach { invalidate => - try { invalidate() } - catch { - case e: Exception => - logger.warn(s"Could not invalidate license cache: ${e.getMessage}") - } - } - } - } - } - - // Step 4: Community default - if (config.apiKey.isDefined) { - logger.error("Could not fetch license — falling back to Community mode") - } else if (config.key.isDefined) { - logger.error("License expired or invalid — running in Community mode.") - } else { - logger.info("Running in Community mode") - } - jwtLicenseManager.resetToCommunity() - LicenseKey.Community - } - - private def logGraceWarning(status: GraceStatus): Unit = status match { - case GraceStatus.EarlyGrace(days) => - logger.warn( - s"License expired $days days ago. Renew at https://portal.softclient4es.com" - ) - case GraceStatus.MidGrace(days, remaining) => - logger.warn( - s"License expired $days days ago. Service will degrade to Community in $remaining days." - ) - case GraceStatus.NotInGrace => - // No warning needed - } -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index db3d454d..cf029102 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -164,6 +164,12 @@ package object licensing { /** Log a warning if the license is in mid-grace period (per-request use) */ def warnIfInGrace(): Unit = () + + /** Refresh the license (re-resolve from backend/cache/config). Returns Right(LicenseKey) on + * success, Left(LicenseError) on failure. Default: returns Right(LicenseKey.Community) (no-op + * for stub implementations). + */ + def refresh(): Either[LicenseError, LicenseKey] = Left(RefreshNotSupported) } sealed trait LicenseError { @@ -187,4 +193,12 @@ package object licensing { def message: String = s"Quota exceeded: $quota ($current/$max)" } + case object RefreshNotSupported extends LicenseError { + def message: String = "License refresh is not supported in Community mode" + override def statusCode: Int = 501 + } + + @deprecated("Use CommunityLicenseManager", "0.20.0") + type DefaultLicenseManager = CommunityLicenseManager + } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala deleted file mode 100644 index 037961eb..00000000 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ApiKeyClientSpec.scala +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.fasterxml.jackson.databind.ObjectMapper -import com.sun.net.httpserver.{HttpExchange, HttpHandler, HttpServer} -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import java.net.InetSocketAddress - -class ApiKeyClientSpec extends AnyFlatSpec with Matchers { - - private val mapper = new ObjectMapper() - - // --- API key format validation --- - - "ApiKeyClient with invalid API key (no prefix)" should "return InvalidLicense immediately" in { - val client = new ApiKeyClient(baseUrl = "http://localhost:1", instanceId = "test") - val result = client.fetchJwt("abc123") - result shouldBe Left(InvalidLicense("Invalid API key format")) - } - - "ApiKeyClient with empty API key" should "return InvalidLicense immediately" in { - val client = new ApiKeyClient(baseUrl = "http://localhost:1", instanceId = "test") - val result = client.fetchJwt("") - result shouldBe Left(InvalidLicense("Invalid API key format")) - } - - "ApiKeyClient with bare prefix (sk-)" should "return InvalidLicense immediately" in { - val client = new ApiKeyClient(baseUrl = "http://localhost:1", instanceId = "test") - val result = client.fetchJwt("sk-") - result shouldBe Left(InvalidLicense("Invalid API key format")) - } - - "ApiKeyClient with minimal valid key (sk-x)" should "proceed to HTTP call" in { - // This will fail with a network error since localhost:1 is not listening, - // but it proves the format validation passed - val client = new ApiKeyClient( - baseUrl = "http://localhost:1", - instanceId = "test", - connectTimeoutMs = 500 - ) - val result = client.fetchJwt("sk-x") - result.isLeft shouldBe true - result.left.get shouldBe a[InvalidLicense] - result.left.get.asInstanceOf[InvalidLicense].reason should startWith("Network error:") - } - - // --- HTTP 200 success --- - - "ApiKeyClient on HTTP 200 with valid JWT" should "return Right(jwt)" in { - withServer { (server, port) => - var capturedAuth: String = null - var capturedContentType: String = null - var capturedBody: String = null - - server.createContext( - ApiKeyClient.TokenPath, - new HttpHandler { - def handle(exchange: HttpExchange): Unit = { - capturedAuth = exchange.getRequestHeaders.getFirst("Authorization") - capturedContentType = exchange.getRequestHeaders.getFirst("Content-Type") - capturedBody = - scala.io.Source.fromInputStream(exchange.getRequestBody, "UTF-8").mkString - val response = - """{"jwt": "eyJhbGciOiJFZERTQSJ9.test.sig", "expires_in": 86400, "message": null}""" - val bytes = response.getBytes("UTF-8") - exchange.sendResponseHeaders(200, bytes.length) - exchange.getResponseBody.write(bytes) - exchange.getResponseBody.close() - } - } - ) - - val client = - new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test-instance") - val result = client.fetchJwt("sk-test-key") - - result shouldBe Right("eyJhbGciOiJFZERTQSJ9.test.sig") - capturedAuth shouldBe "Bearer sk-test-key" - capturedContentType shouldBe "application/json" - - // Verify request body contains instance_id and version - val bodyTree = mapper.readTree(capturedBody) - bodyTree.has("instance_id") shouldBe true - bodyTree.get("instance_id").asText() shouldBe "test-instance" - bodyTree.has("version") shouldBe true - } - } - - "ApiKeyClient on HTTP 200 with non-null message" should "return Right(jwt)" in { - withServer { (server, port) => - server.createContext( - ApiKeyClient.TokenPath, - jsonHandler( - 200, - """{"jwt": "eyJ.test", "expires_in": 86400, "message": "Your license expires in 7 days"}""" - ) - ) - - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") - val result = client.fetchJwt("sk-test") - result shouldBe Right("eyJ.test") - } - } - - // --- HTTP 200 missing jwt --- - - "ApiKeyClient on HTTP 200 with missing jwt field" should "return InvalidLicense" in { - withServer { (server, port) => - server.createContext( - ApiKeyClient.TokenPath, - jsonHandler(200, """{"message": "ok"}""") - ) - - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") - val result = client.fetchJwt("sk-test") - result shouldBe Left(InvalidLicense("Missing jwt in response")) - } - } - - "ApiKeyClient on HTTP 200 with null jwt" should "return InvalidLicense" in { - withServer { (server, port) => - server.createContext( - ApiKeyClient.TokenPath, - jsonHandler(200, """{"jwt": null}""") - ) - - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") - val result = client.fetchJwt("sk-test") - result shouldBe Left(InvalidLicense("Missing jwt in response")) - } - } - - // --- HTTP 401 --- - - "ApiKeyClient on HTTP 401" should "return InvalidLicense with backend message" in { - withServer { (server, port) => - server.createContext( - ApiKeyClient.TokenPath, - jsonHandler( - 401, - """{"error": "api_key_revoked", "message": "API key has been revoked"}""" - ) - ) - - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") - val result = client.fetchJwt("sk-test") - result shouldBe Left(InvalidLicense("API key has been revoked")) - } - } - - "ApiKeyClient on HTTP 401 with malformed body" should "return fallback message" in { - withServer { (server, port) => - server.createContext( - ApiKeyClient.TokenPath, - jsonHandler(401, "not json") - ) - - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") - val result = client.fetchJwt("sk-test") - result shouldBe Left(InvalidLicense("API key rejected (HTTP 401)")) - } - } - - // --- Unexpected HTTP status --- - - "ApiKeyClient on HTTP 500" should "return InvalidLicense with status code" in { - withServer { (server, port) => - server.createContext( - ApiKeyClient.TokenPath, - jsonHandler(500, """{"error": "internal"}""") - ) - - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") - val result = client.fetchJwt("sk-test") - result shouldBe Left(InvalidLicense("Unexpected HTTP status: 500")) - } - } - - // --- Network error --- - - "ApiKeyClient with unreachable server" should "return InvalidLicense with network error" in { - // Start server, capture port, stop immediately -> guaranteed ConnectException - val server = HttpServer.create(new InetSocketAddress(0), 0) - server.start() - val port = server.getAddress.getPort - server.stop(0) - - val client = new ApiKeyClient(baseUrl = s"http://localhost:$port", instanceId = "test") - val result = client.fetchJwt("sk-test") - result.isLeft shouldBe true - result.left.get.asInstanceOf[InvalidLicense].reason should startWith("Network error:") - } - - // --- Timeout --- - - "ApiKeyClient with slow server" should "return InvalidLicense on timeout" in { - val timeout = 100 - withServer { (server, port) => - server.createContext( - ApiKeyClient.TokenPath, - new HttpHandler { - def handle(exchange: HttpExchange): Unit = { - Thread.sleep(timeout * 2) - val bytes = "{}".getBytes("UTF-8") - exchange.sendResponseHeaders(200, bytes.length) - exchange.getResponseBody.write(bytes) - exchange.getResponseBody.close() - } - } - ) - - val client = new ApiKeyClient( - baseUrl = s"http://localhost:$port", - instanceId = "test", - readTimeoutMs = timeout - ) - val result = client.fetchJwt("sk-test") - result.isLeft shouldBe true - result.left.get.asInstanceOf[InvalidLicense].reason should startWith("Network error:") - } - } - - // --- Helpers --- - - private def withServer(f: (HttpServer, Int) => Unit): Unit = { - val server = HttpServer.create(new InetSocketAddress(0), 0) - server.start() - val port = server.getAddress.getPort - try { - f(server, port) - } finally { - server.stop(0) - } - } - - private def jsonHandler(statusCode: Int, body: String): HttpHandler = { - new HttpHandler { - def handle(exchange: HttpExchange): Unit = { - // Consume request body to avoid broken pipe - val is = exchange.getRequestBody - while (is.read() != -1) {} - is.close() - - val bytes = body.getBytes("UTF-8") - if (statusCode >= 400) { - exchange.sendResponseHeaders(statusCode, bytes.length) - exchange.getResponseBody.write(bytes) - } else { - exchange.sendResponseHeaders(statusCode, bytes.length) - exchange.getResponseBody.write(bytes) - } - exchange.getResponseBody.close() - } - } - } -} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala deleted file mode 100644 index d89302aa..00000000 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/InstanceIdSpec.scala +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import java.nio.file.Files - -class InstanceIdSpec extends AnyFlatSpec with Matchers { - - "InstanceId.getOrCreate with empty dir" should "generate UUID and write file" in { - val dir = Files.createTempDirectory("instance-id-test").toFile - try { - val id = InstanceId.getOrCreate(dir.getAbsolutePath) - id should not be empty - // UUID format check - id should fullyMatch regex "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" - // File should exist - val file = new java.io.File(dir, "instance-id") - file.exists() shouldBe true - new String(Files.readAllBytes(file.toPath), "UTF-8") shouldBe id - } finally { - deleteDir(dir) - } - } - - "InstanceId.getOrCreate with existing file" should "return same UUID" in { - val dir = Files.createTempDirectory("instance-id-test").toFile - try { - val id1 = InstanceId.getOrCreate(dir.getAbsolutePath) - val id2 = InstanceId.getOrCreate(dir.getAbsolutePath) - id2 shouldBe id1 - } finally { - deleteDir(dir) - } - } - - "InstanceId.getOrCreate with non-existent dir" should "create dir and generate UUID" in { - val parent = Files.createTempDirectory("instance-id-test").toFile - val dir = new java.io.File(parent, "sub/deep") - try { - dir.exists() shouldBe false - val id = InstanceId.getOrCreate(dir.getAbsolutePath) - id should not be empty - dir.exists() shouldBe true - new java.io.File(dir, "instance-id").exists() shouldBe true - } finally { - deleteDir(parent) - } - } - - "InstanceId.getOrCreate with unwritable dir" should "return fresh UUID without persisting" in { - val dir = Files.createTempDirectory("instance-id-test").toFile - try { - dir.setWritable(false) - val id = InstanceId.getOrCreate(dir.getAbsolutePath) - id should not be empty - // File should NOT exist since dir is not writable - new java.io.File(dir, "instance-id").exists() shouldBe false - } finally { - dir.setWritable(true) - deleteDir(dir) - } - } - - private def deleteDir(dir: java.io.File): Unit = { - if (dir.isDirectory) { - Option(dir.listFiles()).foreach(_.foreach(deleteDir)) - } - dir.delete() - } -} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala deleted file mode 100644 index 4d632521..00000000 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseCacheSpec.scala +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import org.scalatest.BeforeAndAfterEach - -import java.nio.file.{Files, Path} -import java.util.Comparator - -class LicenseCacheSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach { - - private var tempDir: Path = _ - - override def beforeEach(): Unit = { - tempDir = Files.createTempDirectory("license-cache-test") - } - - override def afterEach(): Unit = { - if (tempDir != null && Files.exists(tempDir)) { - // Walk in reverse order (deepest first) to delete nested directories - val stream = Files.walk(tempDir) - try { - stream.sorted(Comparator.reverseOrder[Path]()).forEach(f => Files.deleteIfExists(f)) - } finally { stream.close() } - } - } - - "LicenseCache write + read" should "round-trip a JWT string" in { - val cache = new LicenseCache(tempDir.toString) - val jwt = "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ0ZXN0In0.signature" - cache.write(jwt) - cache.read() shouldBe Some(jwt) - Files.exists(tempDir.resolve(LicenseCache.CacheFileName)) shouldBe true - } - - "LicenseCache read with no cache file" should "return None" in { - val cache = new LicenseCache(tempDir.toString) - cache.read() shouldBe None - } - - "LicenseCache read with empty cache file" should "return None" in { - Files.write(tempDir.resolve(LicenseCache.CacheFileName), Array.emptyByteArray) - val cache = new LicenseCache(tempDir.toString) - cache.read() shouldBe None - } - - "LicenseCache write with missing directory" should "create directory and write" in { - val nested = tempDir.resolve("subdir").resolve("nested") - val cache = new LicenseCache(nested.toString) - cache.write("jwt-string") - cache.read() shouldBe Some("jwt-string") - } - - "LicenseCache write" should "overwrite existing cache" in { - val cache = new LicenseCache(tempDir.toString) - cache.write("jwt-1") - cache.read() shouldBe Some("jwt-1") - cache.write("jwt-2") - cache.read() shouldBe Some("jwt-2") - } - - "LicenseCache delete" should "remove cache file" in { - val cache = new LicenseCache(tempDir.toString) - cache.write("jwt-to-delete") - Files.exists(tempDir.resolve(LicenseCache.CacheFileName)) shouldBe true - cache.delete() - Files.exists(tempDir.resolve(LicenseCache.CacheFileName)) shouldBe false - } - - "LicenseCache delete on missing file" should "not throw" in { - val cache = new LicenseCache(tempDir.toString) - noException should be thrownBy cache.delete() - } - - "LicenseCache read with I/O error" should "return None" in { - // Create a directory named license-cache.jwt — readAllBytes throws IOException on a directory - Files.createDirectory(tempDir.resolve(LicenseCache.CacheFileName)) - val cache = new LicenseCache(tempDir.toString) - cache.read() shouldBe None - } - - "LicenseCache write with I/O error" should "not throw" in { - // Use a regular file as the cacheDir — createDirectories will fail - val blockerFile = Files.createTempFile("license-cache-blocker", ".tmp") - try { - val cache = new LicenseCache(blockerFile.toString) - noException should be thrownBy cache.write("jwt") - } finally { - Files.deleteIfExists(blockerFile) - } - } - - "LicenseCache read with oversized file" should "return None" in { - val bigContent = new Array[Byte](65537) - java.util.Arrays.fill(bigContent, 'x'.toByte) - Files.write(tempDir.resolve(LicenseCache.CacheFileName), bigContent) - val cache = new LicenseCache(tempDir.toString) - cache.read() shouldBe None - } - - "LicenseCache read with symbolic link" should "return None" in { - val target = Files.createFile(tempDir.resolve("target.txt")) - Files.write(target, "jwt-content".getBytes("UTF-8")) - Files.createSymbolicLink(tempDir.resolve(LicenseCache.CacheFileName), target) - val cache = new LicenseCache(tempDir.toString) - cache.read() shouldBe None - } -} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala index fd8e6c02..8cb34b66 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala @@ -21,111 +21,84 @@ import org.scalatest.matchers.should.Matchers class LicenseManagerSpec extends AnyFlatSpec with Matchers { - "DefaultLicenseManager with Community license" should "include MaterializedViews" in { - val manager = new DefaultLicenseManager + "CommunityLicenseManager" should "include MaterializedViews" in { + val manager = new CommunityLicenseManager manager.hasFeature(Feature.MaterializedViews) shouldBe true } it should "include JdbcDriver" in { - val manager = new DefaultLicenseManager + val manager = new CommunityLicenseManager manager.hasFeature(Feature.JdbcDriver) shouldBe true } it should "not include FlightSql" in { - val manager = new DefaultLicenseManager + val manager = new CommunityLicenseManager manager.hasFeature(Feature.FlightSql) shouldBe false } it should "not include Federation" in { - val manager = new DefaultLicenseManager + val manager = new CommunityLicenseManager manager.hasFeature(Feature.Federation) shouldBe false } it should "not include OdbcDriver" in { - val manager = new DefaultLicenseManager + val manager = new CommunityLicenseManager manager.hasFeature(Feature.OdbcDriver) shouldBe false } it should "not include UnlimitedResults" in { - val manager = new DefaultLicenseManager + val manager = new CommunityLicenseManager manager.hasFeature(Feature.UnlimitedResults) shouldBe false } it should "not include AdvancedAggregations" in { - val manager = new DefaultLicenseManager + val manager = new CommunityLicenseManager manager.hasFeature(Feature.AdvancedAggregations) shouldBe false } it should "return Community quotas" in { - val manager = new DefaultLicenseManager + val manager = new CommunityLicenseManager manager.quotas shouldBe Quota.Community } - "DefaultLicenseManager with Pro license" should "include FlightSql" in { - val manager = new DefaultLicenseManager - manager.validate("PRO-test-key") - manager.hasFeature(Feature.FlightSql) shouldBe true - } - - it should "include MaterializedViews" in { - val manager = new DefaultLicenseManager - manager.validate("PRO-test-key") - manager.hasFeature(Feature.MaterializedViews) shouldBe true - } - - it should "include JdbcDriver" in { - val manager = new DefaultLicenseManager - manager.validate("PRO-test-key") - manager.hasFeature(Feature.JdbcDriver) shouldBe true - } - - it should "include UnlimitedResults" in { - val manager = new DefaultLicenseManager - manager.validate("PRO-test-key") - manager.hasFeature(Feature.UnlimitedResults) shouldBe true - } - - it should "not include Federation" in { - val manager = new DefaultLicenseManager - manager.validate("PRO-test-key") - manager.hasFeature(Feature.Federation) shouldBe false + it should "always be Community type" in { + val manager = new CommunityLicenseManager + manager.licenseType shouldBe LicenseType.Community } - it should "return Pro quotas" in { - val manager = new DefaultLicenseManager - manager.validate("PRO-test-key") - manager.quotas shouldBe Quota.Pro + it should "reject any key validation" in { + val manager = new CommunityLicenseManager + manager.validate("PRO-test-key") shouldBe a[Left[_, _]] + manager.validate("ENT-test-key") shouldBe a[Left[_, _]] + manager.validate("anything") shouldBe a[Left[_, _]] + // State remains Community after rejected validation + manager.licenseType shouldBe LicenseType.Community + manager.quotas shouldBe Quota.Community } - "DefaultLicenseManager with Enterprise license" should "include FlightSql" in { - val manager = new DefaultLicenseManager - manager.validate("ENT-test-key") - manager.hasFeature(Feature.FlightSql) shouldBe true + it should "return Left(RefreshNotSupported) on refresh" in { + val manager = new CommunityLicenseManager + manager.refresh() shouldBe Left(RefreshNotSupported) } - it should "include Federation" in { - val manager = new DefaultLicenseManager - manager.validate("ENT-test-key") - manager.hasFeature(Feature.Federation) shouldBe true + "LicenseManager trait" should "be source-compatible" in { + val manager: LicenseManager = new CommunityLicenseManager + manager.licenseType shouldBe LicenseType.Community + manager.quotas shouldBe Quota.Community } - it should "include all features" in { - val manager = new DefaultLicenseManager - manager.validate("ENT-test-key") - Feature.values.foreach { feature => - manager.hasFeature(feature) shouldBe true + it should "default refresh to Left(RefreshNotSupported)" in { + val stub = new LicenseManager { + def validate(key: String): Either[LicenseError, LicenseKey] = Left(InvalidLicense("stub")) + def hasFeature(feature: Feature): Boolean = false + def quotas: Quota = Quota.Community + def licenseType: LicenseType = LicenseType.Community } + stub.refresh() shouldBe Left(RefreshNotSupported) } - it should "return Enterprise quotas" in { - val manager = new DefaultLicenseManager - manager.validate("ENT-test-key") - manager.quotas shouldBe Quota.Enterprise - } - - "LicenseManager trait" should "be source-compatible" in { - val manager: LicenseManager = new DefaultLicenseManager - manager.licenseType shouldBe LicenseType.Community - manager.quotas shouldBe Quota.Community + "DefaultLicenseManager" should "be a deprecated alias for CommunityLicenseManager" in { + val manager: DefaultLicenseManager = new CommunityLicenseManager + manager shouldBe a[CommunityLicenseManager] } } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala new file mode 100644 index 00000000..ef231dea --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala @@ -0,0 +1,132 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import java.util.ServiceLoader + +import scala.jdk.CollectionConverters._ + +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseManagerSpiSpec extends AnyFlatSpec with Matchers { + + "ServiceLoader" should "discover CommunityLicenseManagerSpi" in { + val loader = ServiceLoader.load(classOf[LicenseManagerSpi]) + val spis = loader.iterator().asScala.toSeq + spis should not be empty + spis.exists(_.isInstanceOf[CommunityLicenseManagerSpi]) shouldBe true + } + + "CommunityLicenseManagerSpi" should "have Int.MaxValue priority" in { + new CommunityLicenseManagerSpi().priority shouldBe Int.MaxValue + } + + it should "create a CommunityLicenseManager regardless of mode" in { + val config = ConfigFactory.load() + val spi = new CommunityLicenseManagerSpi() + + // No mode + val mgr1 = spi.create(config) + mgr1.licenseType shouldBe LicenseType.Community + mgr1.refresh() shouldBe Left(RefreshNotSupported) + + // LongRunning mode — still Community + val mgr2 = spi.create(config, Some(LicenseMode.LongRunning)) + mgr2.licenseType shouldBe LicenseType.Community + + // Driver mode — still Community + val mgr3 = spi.create(config, Some(LicenseMode.Driver)) + mgr3.licenseType shouldBe LicenseType.Community + } + + /** Mirrors the SPI resolution logic in ExtensionApi.licenseManager */ + private def resolveManager( + spis: Seq[LicenseManagerSpi], + mode: Option[LicenseMode] = None + ): LicenseManager = { + val config = ConfigFactory.load() + spis + .sortBy(_.priority) + .headOption + .flatMap { spi => + try { Some(spi.create(config, mode)) } + catch { case _: Exception => None } + } + .getOrElse(new CommunityLicenseManager()) + } + + "SPI resolution" should "pick the lowest-priority SPI" in { + val lowPriority = new LicenseManagerSpi { + override def priority: Int = 10 + override def create( + config: Config, + mode: Option[LicenseMode] + ): LicenseManager = new CommunityLicenseManager() { + override def licenseType: LicenseType = LicenseType.Pro + } + } + val community = new CommunityLicenseManagerSpi() + + val mgr = resolveManager(Seq(community, lowPriority)) + mgr.licenseType shouldBe LicenseType.Pro + } + + it should "fall back to CommunityLicenseManager when SPI list is empty" in { + val mgr = resolveManager(Seq.empty) + mgr shouldBe a[CommunityLicenseManager] + mgr.licenseType shouldBe LicenseType.Community + } + + it should "fall back to CommunityLicenseManager when winning SPI throws" in { + val broken = new LicenseManagerSpi { + override def priority: Int = 1 + override def create( + config: Config, + mode: Option[LicenseMode] + ): LicenseManager = throw new RuntimeException("boom") + } + + val mgr = resolveManager(Seq(broken)) + mgr shouldBe a[CommunityLicenseManager] + mgr.licenseType shouldBe LicenseType.Community + } + + it should "pass licenseMode to the winning SPI" in { + var receivedMode: Option[LicenseMode] = None + val spy = new LicenseManagerSpi { + override def priority: Int = 1 + override def create( + config: Config, + mode: Option[LicenseMode] + ): LicenseManager = { + receivedMode = mode + new CommunityLicenseManager() + } + } + + resolveManager(Seq(spy), Some(LicenseMode.LongRunning)) + receivedMode shouldBe Some(LicenseMode.LongRunning) + + resolveManager(Seq(spy), Some(LicenseMode.Driver)) + receivedMode shouldBe Some(LicenseMode.Driver) + + resolveManager(Seq(spy), None) + receivedMode shouldBe None + } +} diff --git a/licensing/testkit/build.sbt b/licensing/testkit/build.sbt deleted file mode 100644 index 7dbcd5f4..00000000 --- a/licensing/testkit/build.sbt +++ /dev/null @@ -1,5 +0,0 @@ -organization := "app.softnetwork.elastic" - -name := "softclient4es-licensing-testkit" - -libraryDependencies += "org.scalatest" %% "scalatest" % Versions.scalatest diff --git a/licensing/testkit/src/main/resources/keys/softclient4es-test.jwk b/licensing/testkit/src/main/resources/keys/softclient4es-test.jwk deleted file mode 100644 index b8b7ec73..00000000 --- a/licensing/testkit/src/main/resources/keys/softclient4es-test.jwk +++ /dev/null @@ -1 +0,0 @@ -{"kty":"OKP","crv":"Ed25519","kid":"softclient4es-test","x":"EGBuSwTrahLvXcMhjr042wzUc4Wm0FTrTALpb56PLNg"} diff --git a/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala b/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala deleted file mode 100644 index f864af79..00000000 --- a/licensing/testkit/src/main/scala/app/softnetwork/elastic/licensing/JwtTestHelper.scala +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} -import com.nimbusds.jose.crypto.Ed25519Signer -import com.nimbusds.jose.jwk.OctetKeyPair -import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} - -import java.util.Date - -object JwtTestHelper { - - /** Static test key pair — the private key is used to sign test JWTs. The corresponding public key - * is in testkit/src/main/resources/keys/softclient4es-test.jwk - */ - private val keyPairJson: String = - """{"kty":"OKP","d":"AanRaois6uVjNOdq46JyJ57LJdrVX3Q-r4KIGwkm37Y","crv":"Ed25519","kid":"softclient4es-test","x":"EGBuSwTrahLvXcMhjr042wzUc4Wm0FTrTALpb56PLNg"}""" - - val keyPair: OctetKeyPair = OctetKeyPair.parse(keyPairJson) - - val publicKey: OctetKeyPair = keyPair.toPublicJWK.asInstanceOf[OctetKeyPair] - - def signJwt( - claims: JWTClaimsSet, - kid: String = "softclient4es-test", - key: OctetKeyPair = keyPair - ): String = { - val header = new JWSHeader.Builder(JWSAlgorithm.EdDSA).keyID(kid).build() - val signed = new SignedJWT(header, claims) - signed.sign(new Ed25519Signer(key)) - signed.serialize() - } - - def proClaimsBuilder( - expiresAt: Date = new Date(System.currentTimeMillis() + 3600000L) - ): JWTClaimsSet.Builder = - new JWTClaimsSet.Builder() - .issuer("https://license.softclient4es.com") - .subject("org-acme-123") - .claim("tier", "pro") - .claim( - "features", - java.util.Arrays.asList( - "materialized_views", - "jdbc_driver", - "unlimited_results", - "flight_sql" - ) - ) - .claim( - "quotas", { - val m = new java.util.LinkedHashMap[String, AnyRef]() - m.put("max_materialized_views", Integer.valueOf(50)) - m.put("max_result_rows", Integer.valueOf(1000000)) - m.put("max_concurrent_queries", Integer.valueOf(50)) - m.put("max_clusters", Integer.valueOf(5)) - m - } - ) - .claim("org_name", "Acme Corp") - .jwtID("lic-001") - .claim("trial", false) - .expirationTime(expiresAt) - - def enterpriseClaimsBuilder( - expiresAt: Date = new Date(System.currentTimeMillis() + 3600000L) - ): JWTClaimsSet.Builder = - new JWTClaimsSet.Builder() - .issuer("https://license.softclient4es.com") - .subject("org-bigcorp-456") - .claim("tier", "enterprise") - .claim( - "features", - java.util.Arrays.asList( - "materialized_views", - "jdbc_driver", - "odbc_driver", - "unlimited_results", - "advanced_aggregations", - "flight_sql", - "federation" - ) - ) - .claim( - "quotas", { - val m = new java.util.LinkedHashMap[String, AnyRef]() - m - } - ) - .claim("org_name", "BigCorp Inc") - .jwtID("lic-002") - .expirationTime(expiresAt) - - def communityClaimsBuilder( - expiresAt: Date = new Date(System.currentTimeMillis() + 3600000L) - ): JWTClaimsSet.Builder = - new JWTClaimsSet.Builder() - .issuer("https://license.softclient4es.com") - .subject("org-free-789") - .claim("tier", "community") - .claim( - "features", - java.util.Arrays.asList("materialized_views", "jdbc_driver") - ) - .claim( - "quotas", { - val m = new java.util.LinkedHashMap[String, AnyRef]() - m.put("max_materialized_views", Integer.valueOf(3)) - m.put("max_result_rows", Integer.valueOf(10000)) - m.put("max_concurrent_queries", Integer.valueOf(5)) - m.put("max_clusters", Integer.valueOf(2)) - m - } - ) - .claim("org_name", "Free User") - .jwtID("lic-003") - .expirationTime(expiresAt) -} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala deleted file mode 100644 index 7355d63d..00000000 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/GraceStatusSpec.scala +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import ch.qos.logback.classic.{Level, Logger} -import ch.qos.logback.classic.spi.ILoggingEvent -import ch.qos.logback.core.read.ListAppender -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import org.slf4j.LoggerFactory - -import java.util.Date -import scala.jdk.CollectionConverters._ - -class GraceStatusSpec extends AnyFlatSpec with Matchers { - - private def manager = new JwtLicenseManager( - publicKeyOverride = Some(JwtTestHelper.publicKey) - ) - - private val gracePeriod14d = java.time.Duration.ofDays(14) - - private def validProJwt: String = - JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - - private def expiredJwt(daysAgo: Int): String = { - val expDate = new Date(System.currentTimeMillis() - daysAgo.toLong * 24 * 3600000L) - JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - } - - private def withLogCapture[T](f: ListAppender[ILoggingEvent] => T): T = { - val loggerName = classOf[JwtLicenseManager].getName - val logger = LoggerFactory.getLogger(loggerName).asInstanceOf[Logger] - val appender = new ListAppender[ILoggingEvent]() - appender.start() - logger.addAppender(appender) - try { - f(appender) - } finally { - logger.detachAppender(appender) - appender.stop() - } - } - - // --- GraceStatus computation (AC #1, #2, #5) --- - - "JwtLicenseManager with valid non-expired JWT" should "have NotInGrace status" in { - val m = manager - val jwt = validProJwt - m.validateWithGracePeriod(jwt, gracePeriod14d) - m.graceStatus shouldBe GraceStatus.NotInGrace - } - - "JwtLicenseManager with JWT expired 1 day ago" should "have EarlyGrace status" in { - val m = manager - val jwt = expiredJwt(1) - m.validateWithGracePeriod(jwt, gracePeriod14d) - m.graceStatus shouldBe a[GraceStatus.EarlyGrace] - m.graceStatus.asInstanceOf[GraceStatus.EarlyGrace].daysExpired shouldBe 1L - } - - "JwtLicenseManager with JWT expired 6 days ago" should "have EarlyGrace status" in { - val m = manager - val jwt = expiredJwt(6) - m.validateWithGracePeriod(jwt, gracePeriod14d) - m.graceStatus shouldBe a[GraceStatus.EarlyGrace] - m.graceStatus.asInstanceOf[GraceStatus.EarlyGrace].daysExpired shouldBe 6L - } - - "JwtLicenseManager with JWT expired 7 days ago" should "have MidGrace status" in { - val m = manager - val jwt = expiredJwt(7) - m.validateWithGracePeriod(jwt, gracePeriod14d) - m.graceStatus shouldBe a[GraceStatus.MidGrace] - val mg = m.graceStatus.asInstanceOf[GraceStatus.MidGrace] - mg.daysExpired shouldBe 7L - mg.daysRemaining shouldBe 7L - } - - "JwtLicenseManager with JWT expired 13 days ago" should "have MidGrace status" in { - val m = manager - val jwt = expiredJwt(13) - m.validateWithGracePeriod(jwt, gracePeriod14d) - m.graceStatus shouldBe a[GraceStatus.MidGrace] - val mg = m.graceStatus.asInstanceOf[GraceStatus.MidGrace] - mg.daysExpired shouldBe 13L - mg.daysRemaining shouldBe 1L - } - - "JwtLicenseManager after validate() (no grace)" should "have NotInGrace status" in { - val m = manager - val jwt = validProJwt - m.validate(jwt) - m.graceStatus shouldBe GraceStatus.NotInGrace - } - - "JwtLicenseManager after resetToCommunity" should "have NotInGrace status" in { - val m = manager - val jwt = expiredJwt(7) - m.validateWithGracePeriod(jwt, gracePeriod14d) - m.graceStatus shouldBe a[GraceStatus.MidGrace] - m.resetToCommunity() - m.graceStatus shouldBe GraceStatus.NotInGrace - } - - // --- Custom grace period scaling (AC #5) --- - - "JwtLicenseManager with 30-day grace period" should "have earlyThreshold at 15 days" in { - val m = manager - val jwt = expiredJwt(10) - val gracePeriod30d = java.time.Duration.ofDays(30) - m.validateWithGracePeriod(jwt, gracePeriod30d) - m.graceStatus shouldBe a[GraceStatus.EarlyGrace] // 10 < 15 (30/2) - } - - "JwtLicenseManager with 30-day grace period, expired 20 days" should "have MidGrace" in { - val m = manager - val jwt = expiredJwt(20) - val gracePeriod30d = java.time.Duration.ofDays(30) - m.validateWithGracePeriod(jwt, gracePeriod30d) - m.graceStatus shouldBe a[GraceStatus.MidGrace] - val mg = m.graceStatus.asInstanceOf[GraceStatus.MidGrace] - mg.daysExpired shouldBe 20L - mg.daysRemaining shouldBe 10L - } - - // --- wasDegraded (AC #4) --- - - "JwtLicenseManager.wasDegraded" should "be false initially" in { - val m = manager - m.wasDegraded shouldBe false - } - - "JwtLicenseManager.wasDegraded" should "be true after resetToCommunity" in { - val m = manager - m.resetToCommunity() - m.wasDegraded shouldBe true - } - - "JwtLicenseManager.wasDegraded" should "be false after successful validate" in { - val m = manager - m.resetToCommunity() - m.wasDegraded shouldBe true - m.validate(validProJwt) - m.wasDegraded shouldBe false - } - - "JwtLicenseManager.wasDegraded" should "be false after successful validateWithGracePeriod" in { - val m = manager - m.resetToCommunity() - m.wasDegraded shouldBe true - val jwt = expiredJwt(5) // within grace - m.validateWithGracePeriod(jwt, gracePeriod14d) - m.wasDegraded shouldBe false - } - - // --- warnIfInGrace (AC #2) --- - - private val degradationMessagePattern = "degrade to Community" - - "JwtLicenseManager.warnIfInGrace with non-expired JWT" should "not emit a degradation warning" in { - val m = manager - withLogCapture { appender => - m.validate(validProJwt) - appender.list.clear() - m.warnIfInGrace() - m.graceStatus shouldBe GraceStatus.NotInGrace - val degradationWarnings = appender.list.asScala - .filter(e => - e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern) - ) - degradationWarnings shouldBe empty - } - } - - "JwtLicenseManager.warnIfInGrace with early grace JWT" should "not emit a degradation warning" in { - val m = manager - withLogCapture { appender => - m.validateWithGracePeriod(expiredJwt(3), gracePeriod14d) - appender.list.clear() - m.warnIfInGrace() - m.graceStatus shouldBe a[GraceStatus.EarlyGrace] - val degradationWarnings = appender.list.asScala - .filter(e => - e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern) - ) - degradationWarnings shouldBe empty - } - } - - "JwtLicenseManager.warnIfInGrace with mid-grace JWT" should "emit a degradation warning" in { - val m = manager - withLogCapture { appender => - m.validateWithGracePeriod(expiredJwt(10), gracePeriod14d) - appender.list.clear() - m.warnIfInGrace() - m.graceStatus shouldBe a[GraceStatus.MidGrace] - val degradationWarnings = appender.list.asScala - .filter(e => - e.getLevel == Level.WARN && e.getFormattedMessage.contains(degradationMessagePattern) - ) - degradationWarnings should not be empty - } - } -} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala deleted file mode 100644 index 0de7a2a4..00000000 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/JwtLicenseManagerSpec.scala +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.nimbusds.jose.jwk.Curve -import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator -import com.nimbusds.jwt.JWTClaimsSet -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import java.time.Duration -import java.util.Date - -class JwtLicenseManagerSpec extends AnyFlatSpec with Matchers { - - private def manager = new JwtLicenseManager( - publicKeyOverride = Some(JwtTestHelper.publicKey) - ) - - "JwtLicenseManager default state" should "be Community tier" in { - val m = manager - m.licenseType shouldBe LicenseType.Community - m.quotas shouldBe Quota.Community - } - - "JwtLicenseManager with valid Pro JWT" should "return Right(LicenseKey) with correct tier" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - val result = m.validate(jwt) - result shouldBe a[Right[_, _]] - val key = result.toOption.get - key.licenseType shouldBe LicenseType.Pro - key.id shouldBe "org-acme-123" - } - - it should "have correct features" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - m.validate(jwt) - m.hasFeature(Feature.MaterializedViews) shouldBe true - m.hasFeature(Feature.JdbcDriver) shouldBe true - m.hasFeature(Feature.UnlimitedResults) shouldBe true - m.hasFeature(Feature.FlightSql) shouldBe true - m.hasFeature(Feature.Federation) shouldBe false - m.hasFeature(Feature.OdbcDriver) shouldBe false - } - - it should "have JWT-embedded quotas (not tier defaults)" in { - val m = manager - val claims = JwtTestHelper - .proClaimsBuilder() - .claim( - "quotas", { - val q = new java.util.LinkedHashMap[String, AnyRef]() - q.put("max_result_rows", Integer.valueOf(500000)) - q.put("max_clusters", Integer.valueOf(3)) - q - } - ) - .build() - val jwt = JwtTestHelper.signJwt(claims) - m.validate(jwt) - m.quotas.maxQueryResults shouldBe Some(500000) - m.quotas.maxClusters shouldBe Some(3) - m.quotas.maxMaterializedViews shouldBe None // not in JWT → None (unlimited) - m.quotas.maxConcurrentQueries shouldBe None - } - - it should "have correct expiresAt" in { - val m = manager - val expDate = new Date(System.currentTimeMillis() + 7200000L) - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - val result = m.validate(jwt) - val key = result.toOption.get - key.expiresAt shouldBe defined - // JWT exp is truncated to seconds - key.expiresAt.get.getEpochSecond shouldBe (expDate.getTime / 1000) - } - - it should "populate metadata" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - val result = m.validate(jwt) - val key = result.toOption.get - key.metadata("org_name") shouldBe "Acme Corp" - key.metadata("jti") shouldBe "lic-001" - key.metadata("trial") shouldBe "false" - } - - "JwtLicenseManager with valid Enterprise JWT" should "have all 7 features" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) - m.validate(jwt) - Feature.values.foreach { feature => - m.hasFeature(feature) shouldBe true - } - } - - it should "have unlimited quotas when JWT quotas object is empty" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) - m.validate(jwt) - m.quotas.maxMaterializedViews shouldBe None - m.quotas.maxQueryResults shouldBe None - m.quotas.maxConcurrentQueries shouldBe None - m.quotas.maxClusters shouldBe None - } - - "JwtLicenseManager with valid Community JWT" should "have only community features" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.communityClaimsBuilder().build()) - m.validate(jwt) - m.hasFeature(Feature.MaterializedViews) shouldBe true - m.hasFeature(Feature.JdbcDriver) shouldBe true - m.hasFeature(Feature.FlightSql) shouldBe false - m.hasFeature(Feature.Federation) shouldBe false - } - - "JwtLicenseManager with tampered signature" should "return Left(InvalidLicense)" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - // Tamper by replacing a character in the signature part - val parts = jwt.split("\\.") - val tampered = parts(0) + "." + parts(1) + "." + parts(2).reverse - val result = m.validate(tampered) - result shouldBe a[Left[_, _]] - result.left.toOption.get shouldBe a[InvalidLicense] - result.left.toOption.get.message should include("Invalid signature") - } - - "JwtLicenseManager with expired JWT" should "return Left(ExpiredLicense)" in { - val m = manager - val expDate = new Date(System.currentTimeMillis() - 3600000L) // 1 hour ago - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - val result = m.validate(jwt) - result shouldBe a[Left[_, _]] - result.left.toOption.get shouldBe an[ExpiredLicense] - } - - "JwtLicenseManager with unknown kid" should "return Left(InvalidLicense) when no override" in { - val m = new JwtLicenseManager(publicKeyOverride = None) - val jwt = JwtTestHelper.signJwt( - JwtTestHelper.proClaimsBuilder().build(), - kid = "unknown-key-2099" - ) - val result = m.validate(jwt) - result shouldBe a[Left[_, _]] - result.left.toOption.get.message should include("Unknown key ID") - } - - "JwtLicenseManager with wrong issuer" should "return Left(InvalidLicense)" in { - val m = manager - val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) - .issuer("https://evil.example.com") - .build() - val jwt = JwtTestHelper.signJwt(claims) - val result = m.validate(jwt) - result shouldBe a[Left[_, _]] - result.left.toOption.get.message should include("Invalid issuer") - } - - "JwtLicenseManager with malformed string" should "return Left(InvalidLicense)" in { - val m = manager - val result = m.validate("not-a-jwt-at-all") - result shouldBe a[Left[_, _]] - result.left.toOption.get.message should include("Malformed JWT") - } - - "JwtLicenseManager with unknown tier" should "default to Community" in { - val m = manager - val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) - .claim("tier", "platinum") - .build() - val jwt = JwtTestHelper.signJwt(claims) - val result = m.validate(jwt) - result shouldBe a[Right[_, _]] - result.toOption.get.licenseType shouldBe LicenseType.Community - } - - "JwtLicenseManager with unknown feature strings" should "silently ignore them" in { - val m = manager - val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) - .claim("features", java.util.Arrays.asList("materialized_views", "time_travel", "warp_drive")) - .build() - val jwt = JwtTestHelper.signJwt(claims) - val result = m.validate(jwt) - result shouldBe a[Right[_, _]] - val key = result.toOption.get - key.features shouldBe Set(Feature.MaterializedViews) - } - - "JwtLicenseManager re-validation" should "replace the previous license" in { - val m = manager - val proJwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - m.validate(proJwt) - m.licenseType shouldBe LicenseType.Pro - - val entJwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) - m.validate(entJwt) - m.licenseType shouldBe LicenseType.Enterprise - } - - "JwtLicenseManager quota mapping" should "map null/missing to None (unlimited)" in { - val m = manager - val claims = new JWTClaimsSet.Builder(JwtTestHelper.proClaimsBuilder().build()) - .claim( - "quotas", { - val q = new java.util.LinkedHashMap[String, AnyRef]() - q.put("max_result_rows", null) - q.put("max_clusters", Integer.valueOf(10)) - q - } - ) - .build() - val jwt = JwtTestHelper.signJwt(claims) - m.validate(jwt) - m.quotas.maxQueryResults shouldBe None - m.quotas.maxClusters shouldBe Some(10) - m.quotas.maxMaterializedViews shouldBe None - } - - // --- validateWithGracePeriod tests --- - - "validateWithGracePeriod with non-expired JWT" should "return Right (same as validate)" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - val result = m.validateWithGracePeriod(jwt, Duration.ofDays(14)) - result shouldBe a[Right[_, _]] - result.toOption.get.licenseType shouldBe LicenseType.Pro - } - - "validateWithGracePeriod with expired JWT within grace" should "return Right (grace mode)" in { - val m = manager - val expDate = new Date(System.currentTimeMillis() - 3600000L) // 1 hour ago - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - // Grace period of 14 days — 1 hour ago is well within - val result = m.validateWithGracePeriod(jwt, Duration.ofDays(14)) - result shouldBe a[Right[_, _]] - result.toOption.get.licenseType shouldBe LicenseType.Pro - } - - "validateWithGracePeriod with expired JWT beyond grace" should "return Left(ExpiredLicense)" in { - val m = manager - val expDate = new Date(System.currentTimeMillis() - 30L * 24 * 3600000L) // 30 days ago - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - val result = m.validateWithGracePeriod(jwt, Duration.ofDays(14)) - result shouldBe a[Left[_, _]] - result.left.toOption.get shouldBe an[ExpiredLicense] - } - - "validateWithGracePeriod with zero grace" should "reject all expired JWTs" in { - val m = manager - val expDate = new Date(System.currentTimeMillis() - 1000L) // 1 second ago - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - val result = m.validateWithGracePeriod(jwt, Duration.ZERO) - result shouldBe a[Left[_, _]] - result.left.toOption.get shouldBe an[ExpiredLicense] - } - - "validateWithGracePeriod with 365-day grace" should "accept recently expired JWTs" in { - val m = manager - val expDate = new Date(System.currentTimeMillis() - 60L * 24 * 3600000L) // 60 days ago - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - val result = m.validateWithGracePeriod(jwt, Duration.ofDays(365)) - result shouldBe a[Right[_, _]] - result.toOption.get.licenseType shouldBe LicenseType.Pro - } - - // --- resetToCommunity tests --- - - "resetToCommunity after Pro validation" should "revert to Community tier and quotas" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - m.validate(jwt) - m.licenseType shouldBe LicenseType.Pro - - m.resetToCommunity() - m.licenseType shouldBe LicenseType.Community - m.quotas shouldBe Quota.Community - } - - "resetToCommunity then re-validate" should "update state to Pro again" in { - val m = manager - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - m.validate(jwt) - m.resetToCommunity() - m.licenseType shouldBe LicenseType.Community - - m.validate(jwt) - m.licenseType shouldBe LicenseType.Pro - } -} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala deleted file mode 100644 index 2088fa13..00000000 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeyVerifierSpec.scala +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import com.nimbusds.jose.jwk.OctetKeyPair -import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator -import com.nimbusds.jose.jwk.Curve -import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class LicenseKeyVerifierSpec extends AnyFlatSpec with Matchers { - - "LicenseKeyVerifier.verify" should "return true for a valid signature" in { - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - val signed = SignedJWT.parse(jwt) - LicenseKeyVerifier.verify(signed, JwtTestHelper.publicKey) shouldBe true - } - - it should "return false for a tampered payload" in { - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - val signed = SignedJWT.parse(jwt) - // Tamper: create a new JWT with a different subject but the same signature - val tampered = new SignedJWT( - signed.getHeader.toBase64URL, - new JWTClaimsSet.Builder(signed.getJWTClaimsSet) - .subject("tampered") - .build() - .toPayload - .toBase64URL, - signed.getSignature - ) - LicenseKeyVerifier.verify(tampered, JwtTestHelper.publicKey) shouldBe false - } - - it should "return false when verified against a different key" in { - val otherKeyPair = new OctetKeyPairGenerator(Curve.Ed25519) - .keyID("other-key") - .generate() - val jwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - val signed = SignedJWT.parse(jwt) - LicenseKeyVerifier.verify( - signed, - otherKeyPair.toPublicJWK.asInstanceOf[OctetKeyPair] - ) shouldBe false - } - - "LicenseKeyVerifier.loadPublicKey" should "load the test key by kid from classpath" in { - val result = LicenseKeyVerifier.loadPublicKey("softclient4es-test") - result shouldBe a[Right[_, _]] - val key = result.toOption.get - key.getKeyID shouldBe "softclient4es-test" - key.getCurve.getName shouldBe "Ed25519" - } - - it should "return Left for an unknown kid" in { - val result = LicenseKeyVerifier.loadPublicKey("unknown-key-id") - result shouldBe a[Left[_, _]] - result.left.toOption.get shouldBe a[InvalidLicense] - result.left.toOption.get.asInstanceOf[InvalidLicense].reason should include("Unknown key ID") - } -} diff --git a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala b/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala deleted file mode 100644 index 6c1ebce4..00000000 --- a/licensing/testkit/src/test/scala/app/softnetwork/elastic/licensing/LicenseResolverSpec.scala +++ /dev/null @@ -1,645 +0,0 @@ -/* - * Copyright 2025 SOFTNETWORK - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.softnetwork.elastic.licensing - -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import java.nio.file.Files -import java.util.Date -import scala.concurrent.duration._ - -class LicenseResolverSpec extends AnyFlatSpec with Matchers { - - private def manager = new JwtLicenseManager( - publicKeyOverride = Some(JwtTestHelper.publicKey) - ) - - private def defaultConfig( - licenseKey: Option[String] = None, - apiKey: Option[String] = None - ): LicenseConfig = - LicenseConfig( - key = licenseKey, - apiKey = apiKey, - apiUrl = "https://license.softclient4es.com", - refreshEnabled = true, - refreshInterval = 24.hours, - telemetryEnabled = true, - gracePeriod = 14.days, - cacheDir = "/tmp/test-cache" - ) - - private def validProJwt: String = - JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - - private def expiredJwt(daysAgo: Int): String = { - val expDate = new Date(System.currentTimeMillis() - daysAgo.toLong * 24 * 3600000L) - JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder(expDate).build()) - } - - // --- Step 1: Static JWT --- - - "LicenseResolver with valid static JWT" should "resolve to that JWT's tier" in { - val m = manager - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(validProJwt)), - jwtLicenseManager = m - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - } - - "LicenseResolver with valid static JWT + API key" should "use static JWT, not call API key" in { - val m = manager - var apiKeyCalled = false - val fetcher: String => Either[LicenseError, String] = { _ => - apiKeyCalled = true - Right(validProJwt) - } - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(validProJwt), apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - apiKeyCalled shouldBe false - } - - "LicenseResolver with expired static JWT within grace period" should "resolve with grace" in { - val m = manager - val jwt = expiredJwt(1) // expired 1 day ago, grace = 14 days - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(jwt)), - jwtLicenseManager = m - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - } - - // --- Step 1 → Step 2 fallthrough --- - - "LicenseResolver with expired static JWT beyond grace + API key" should "call API key fetcher" in { - val m = manager - val jwt = expiredJwt(30) // expired 30 days ago, grace = 14 days - val freshJwt = validProJwt - val fetcher: String => Either[LicenseError, String] = { _ => Right(freshJwt) } - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(jwt), apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - } - - "LicenseResolver with invalid static JWT (bad signature)" should "fall through to API key" in { - val m = manager - val jwt = validProJwt - val tampered = jwt.split("\\.")(0) + "." + jwt.split("\\.")(1) + "." + jwt - .split("\\.")(2) - .reverse - var apiKeyCalled = false - val fetcher: String => Either[LicenseError, String] = { _ => - apiKeyCalled = true - Right(validProJwt) - } - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(tampered), apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - resolver.resolve() - apiKeyCalled shouldBe true - } - - // --- Step 2: API key --- - - "LicenseResolver with API key but no fetcher" should "skip step 2 and degrade to Community" in { - val m = manager - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = None - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Community - } - - "LicenseResolver with no static JWT + API key fetch succeeds" should "resolve to fetched JWT and store in manager" in { - val m = manager - val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - // AC #8: JWT stored in memory for per-request enforcement - m.licenseType shouldBe LicenseType.Pro - m.quotas shouldBe Quota.Pro - } - - // --- Step 2 → Step 3 fallthrough --- - - "LicenseResolver with API key fetch fails + cache hit" should "resolve to cached JWT" in { - val m = manager - val cachedJwt = validProJwt - val fetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val reader: () => Option[String] = () => Some(cachedJwt) - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - } - - "LicenseResolver with cached JWT expired within grace" should "resolve with grace" in { - val m = manager - val cachedJwt = expiredJwt(1) // 1 day ago, grace = 14 days - val fetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val reader: () => Option[String] = () => Some(cachedJwt) - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - } - - "LicenseResolver with cached JWT expired beyond grace" should "degrade to Community" in { - val m = manager - val cachedJwt = expiredJwt(30) - val fetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val reader: () => Option[String] = () => Some(cachedJwt) - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Community - } - - // --- Step 3 → Step 4 fallthrough --- - - "LicenseResolver with API key fetch fails + no cache" should "degrade to Community" in { - val m = manager - val fetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Community - } - - // --- Step 4: Community default --- - - "LicenseResolver with no JWT and no API key" should "default to Community" in { - val m = manager - val resolver = new LicenseResolver( - config = defaultConfig(), - jwtLicenseManager = m - ) - val key = resolver.resolve() - key shouldBe LicenseKey.Community - } - - // --- resetToCommunity on re-resolution --- - - // --- Tier change logging (AC #7) --- - - "LicenseResolver cold start upgrade (Community -> Pro)" should "resolve to Pro with correct manager state" in { - val m = manager - // Fresh manager defaults to Community - m.licenseType shouldBe LicenseType.Community - val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - m.licenseType shouldBe LicenseType.Pro - m.quotas shouldBe Quota.Pro - } - - "LicenseResolver upgrade (Pro -> Enterprise)" should "resolve to Enterprise" in { - val m = manager - // First resolve with Pro via static key - val proJwt = validProJwt - val resolver1 = new LicenseResolver( - config = defaultConfig(licenseKey = Some(proJwt)), - jwtLicenseManager = m - ) - resolver1.resolve() - m.licenseType shouldBe LicenseType.Pro - - // Second resolve with Enterprise via API key fetcher - val entJwt = JwtTestHelper.signJwt(JwtTestHelper.enterpriseClaimsBuilder().build()) - val fetcher: String => Either[LicenseError, String] = { _ => Right(entJwt) } - val resolver2 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver2.resolve() - key.licenseType shouldBe LicenseType.Enterprise - m.licenseType shouldBe LicenseType.Enterprise - } - - "LicenseResolver downgrade (Pro -> Community)" should "resolve to Community" in { - val m = manager - // First resolve with Pro via static key - val resolver1 = new LicenseResolver( - config = defaultConfig(licenseKey = Some(validProJwt)), - jwtLicenseManager = m - ) - resolver1.resolve() - m.licenseType shouldBe LicenseType.Pro - - // Second resolve with Community via API key fetcher - val communityJwt = JwtTestHelper.signJwt(JwtTestHelper.communityClaimsBuilder().build()) - val fetcher: String => Either[LicenseError, String] = { _ => Right(communityJwt) } - val resolver2 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver2.resolve() - key.licenseType shouldBe LicenseType.Community - m.licenseType shouldBe LicenseType.Community - m.quotas shouldBe Quota.Community - } - - "LicenseResolver same tier (Pro -> Pro)" should "resolve to Pro without tier change" in { - val m = manager - // First resolve with Pro via static key - val resolver1 = new LicenseResolver( - config = defaultConfig(licenseKey = Some(validProJwt)), - jwtLicenseManager = m - ) - resolver1.resolve() - m.licenseType shouldBe LicenseType.Pro - - // Second resolve with different Pro JWT via API key fetcher - val anotherProJwt = JwtTestHelper.signJwt(JwtTestHelper.proClaimsBuilder().build()) - val fetcher: String => Either[LicenseError, String] = { _ => Right(anotherProJwt) } - val resolver2 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver2.resolve() - key.licenseType shouldBe LicenseType.Pro - m.licenseType shouldBe LicenseType.Pro - } - - // --- resetToCommunity on re-resolution --- - - "LicenseResolver degrading to Community" should "reset manager state from prior Pro" in { - val m = manager - // First, resolve with a valid Pro JWT - val resolver1 = new LicenseResolver( - config = defaultConfig(licenseKey = Some(validProJwt)), - jwtLicenseManager = m - ) - resolver1.resolve() - m.licenseType shouldBe LicenseType.Pro - - // Now resolve with nothing → should reset to Community - val resolver2 = new LicenseResolver( - config = defaultConfig(), - jwtLicenseManager = m - ) - resolver2.resolve() - m.licenseType shouldBe LicenseType.Community - m.quotas shouldBe Quota.Community - } - - // --- Cache writer/invalidator (Story 5.5) --- - - "LicenseResolver cache writer on API key fetch success" should "write JWT to cache" in { - val m = manager - var cachedJwt: Option[String] = None - val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } - val writer: String => Unit = { jwt => cachedJwt = Some(jwt) } - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheWriter = Some(writer) - ) - resolver.resolve() - cachedJwt shouldBe Some(validProJwt) - } - - "LicenseResolver cache writer on static JWT success" should "not write to cache" in { - val m = manager - var cacheWriteCalled = false - val writer: String => Unit = { _ => cacheWriteCalled = true } - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(validProJwt)), - jwtLicenseManager = m, - cacheWriter = Some(writer) - ) - resolver.resolve() - cacheWriteCalled shouldBe false - } - - "LicenseResolver cache invalidator on stale cached JWT" should "delete cache file" in { - val m = manager - var invalidateCalled = false - val invalidator: () => Unit = () => { invalidateCalled = true } - val staleJwt = expiredJwt(30) // expired beyond 14-day grace - val fetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val reader: () => Option[String] = () => Some(staleJwt) - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader), - cacheInvalidator = Some(invalidator) - ) - resolver.resolve() - invalidateCalled shouldBe true - } - - "LicenseResolver cache invalidator on successful resolution" should "not be called" in { - val m = manager - var invalidateCalled = false - val invalidator: () => Unit = () => { invalidateCalled = true } - val cachedJwt = validProJwt - val fetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val reader: () => Option[String] = () => Some(cachedJwt) - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader), - cacheInvalidator = Some(invalidator) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - invalidateCalled shouldBe false - } - - "LicenseResolver cache round-trip" should "use cached JWT when API key fetch fails on second resolve" in { - val m = manager - var cachedJwt: Option[String] = None - val proJwt = validProJwt - - // First resolve: API key succeeds, writes to cache - var fetchSucceeds = true - val fetcher: String => Either[LicenseError, String] = { _ => - if (fetchSucceeds) Right(proJwt) - else Left(InvalidLicense("Network error")) - } - val writer: String => Unit = { jwt => cachedJwt = Some(jwt) } - val reader: () => Option[String] = () => cachedJwt - - val resolver1 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader), - cacheWriter = Some(writer) - ) - resolver1.resolve().licenseType shouldBe LicenseType.Pro - cachedJwt shouldBe Some(proJwt) - - // Second resolve: API key fails, falls back to cache - fetchSucceeds = false - val resolver2 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader), - cacheWriter = Some(writer) - ) - resolver2.resolve().licenseType shouldBe LicenseType.Pro - } - - "LicenseResolver cache writer failure" should "still resolve successfully" in { - val m = manager - val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } - val writer: String => Unit = { _ => throw new RuntimeException("Disk full") } - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheWriter = Some(writer) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - } - - "LicenseResolver with real LicenseCache" should "write and delete cache file" in { - val m = manager - val tempDir = Files.createTempDirectory("license-resolver-cache-test") - try { - val cache = new LicenseCache(tempDir.toString) - val proJwt = validProJwt - - // First resolve: API key succeeds -> writes to disk - val fetcher: String => Either[LicenseError, String] = { _ => Right(proJwt) } - val resolver1 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(() => cache.read()), - cacheWriter = Some(jwt => cache.write(jwt)), - cacheInvalidator = Some(() => cache.delete()) - ) - resolver1.resolve().licenseType shouldBe LicenseType.Pro - val cacheFile = tempDir.resolve(LicenseCache.CacheFileName) - Files.exists(cacheFile) shouldBe true - cache.read() shouldBe Some(proJwt) - - // Second resolve: stale cache -> invalidated - val staleJwt = expiredJwt(30) - cache.write(staleJwt) - val failingFetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val resolver2 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(failingFetcher), - cacheReader = Some(() => cache.read()), - cacheWriter = Some(jwt => cache.write(jwt)), - cacheInvalidator = Some(() => cache.delete()) - ) - resolver2.resolve().licenseType shouldBe LicenseType.Community - Files.exists(cacheFile) shouldBe false - } finally { - val stream = Files.list(tempDir) - try { stream.forEach(f => Files.deleteIfExists(f)) } - finally { stream.close() } - Files.deleteIfExists(tempDir) - } - } - - // --- Grace status at startup (AC #1, #2, #3) --- - - "LicenseResolver with early grace static JWT" should "resolve with full access" in { - val m = manager - val jwt = expiredJwt(3) // 3 days ago, grace = 14d, earlyThreshold = 7d - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(jwt)), - jwtLicenseManager = m - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - m.graceStatus shouldBe a[GraceStatus.EarlyGrace] - } - - "LicenseResolver with mid-grace static JWT" should "resolve with full access" in { - val m = manager - val jwt = expiredJwt(10) // 10 days ago, grace = 14d - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(jwt)), - jwtLicenseManager = m - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - m.graceStatus shouldBe a[GraceStatus.MidGrace] - } - - "LicenseResolver with beyond-grace static JWT and no API key" should "degrade to Community" in { - val m = manager - val jwt = expiredJwt(30) // 30 days ago, grace = 14d - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(jwt)), - jwtLicenseManager = m - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Community - m.wasDegraded shouldBe true - } - - "LicenseResolver with mid-grace cached JWT" should "resolve with full access and correct grace status" in { - val m = manager - val jwt = expiredJwt(10) - val fetcher: String => Either[LicenseError, String] = { _ => - Left(InvalidLicense("Network error")) - } - val reader: () => Option[String] = () => Some(jwt) - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher), - cacheReader = Some(reader) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - m.graceStatus shouldBe a[GraceStatus.MidGrace] - } - - // --- Restoration detection (AC #4) --- - - "LicenseResolver restoration after degradation" should "log restored when renewed via API key" in { - val m = manager - // Simulate degradation: resolve with expired JWT, no API key - val expJwt = expiredJwt(30) - val resolver1 = new LicenseResolver( - config = defaultConfig(licenseKey = Some(expJwt)), - jwtLicenseManager = m - ) - resolver1.resolve().licenseType shouldBe LicenseType.Community - m.wasDegraded shouldBe true - - // Restore via API key - val freshJwt = validProJwt - val fetcher: String => Either[LicenseError, String] = { _ => Right(freshJwt) } - val resolver2 = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - val key = resolver2.resolve() - key.licenseType shouldBe LicenseType.Pro - m.wasDegraded shouldBe false - } - - "LicenseResolver restoration after degradation" should "log restored when renewed via static JWT" in { - val m = manager - // Degrade - m.resetToCommunity() - m.wasDegraded shouldBe true - - // Restore via static JWT - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(validProJwt)), - jwtLicenseManager = m - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - m.wasDegraded shouldBe false - } - - "LicenseResolver cold start" should "not report restoration" in { - val m = manager - m.wasDegraded shouldBe false - val fetcher: String => Either[LicenseError, String] = { _ => Right(validProJwt) } - val resolver = new LicenseResolver( - config = defaultConfig(apiKey = Some("sk-test")), - jwtLicenseManager = m, - apiKeyFetcher = Some(fetcher) - ) - resolver.resolve().licenseType shouldBe LicenseType.Pro - m.wasDegraded shouldBe false - } - - "LicenseResolver cache fallback without API key" should "use cached JWT when static JWT is invalid" in { - val m = manager - val cachedJwt = validProJwt - val tampered = validProJwt.split("\\.")(0) + "." + validProJwt.split("\\.")(1) + "." + - validProJwt.split("\\.")(2).reverse - val reader: () => Option[String] = () => Some(cachedJwt) - val resolver = new LicenseResolver( - config = defaultConfig(licenseKey = Some(tampered)), - jwtLicenseManager = m, - cacheReader = Some(reader) - ) - val key = resolver.resolve() - key.licenseType shouldBe LicenseType.Pro - } -} diff --git a/project/Versions.scala b/project/Versions.scala index 18b4c77f..e02c0d3e 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -60,10 +60,4 @@ object Versions { val hadoop = "3.4.2" // must match hadoop-client in core/build.sbt val gcsConnector = "hadoop3-2.2.24" - - val nimbusJoseJwt = "10.3" - - val bouncyCastle = "1.80" - - val tink = "1.16.0" }