diff --git a/plugin/plugin.go b/plugin/plugin.go index 66573dc42a..8f9851bce9 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -128,6 +128,15 @@ func (p *Plugin) Version() string { return p.version } +func (p *Plugin) Meta() Meta { + return Meta{ + Team: p.team, + Kind: cqapi.PluginKind(p.kind), + Name: p.name, + SkipUsageClient: p.skipUsageClient, + } +} + // SetSkipUsageClient sets whether the usage client should be skipped func (p *Plugin) SetSkipUsageClient(v bool) { p.skipUsageClient = v @@ -205,12 +214,7 @@ func (p *Plugin) Init(ctx context.Context, spec []byte, options NewClientOptions } } - options.PluginMeta = Meta{ - Team: p.team, - Kind: cqapi.PluginKind(p.kind), - Name: p.name, - SkipUsageClient: p.skipUsageClient, - } + options.PluginMeta = p.Meta() p.client, err = p.newClient(ctx, p.logger, spec, options) if err != nil { diff --git a/premium/offline.go b/premium/offline.go index 31dab33df3..73aa56bf1f 100644 --- a/premium/offline.go +++ b/premium/offline.go @@ -7,13 +7,18 @@ import ( "encoding/json" "errors" "os" + "path/filepath" + "slices" + "strings" "time" + "github.com/cloudquery/plugin-sdk/v4/plugin" "github.com/rs/zerolog" ) type License struct { - LicensedTo string `json:"licensed_to"` // Customers name, e.g. "Acme Inc" + LicensedTo string `json:"licensed_to"` // Customers name, e.g. "Acme Inc" + Plugins []string `json:"plugins,omitempty"` // List of plugins, each in the format //, e.g. "cloudquery/source/aws". Optional, if empty all plugins are allowed. IssuedAt time.Time `json:"issued_at"` ValidFrom time.Time `json:"valid_from"` ExpiresAt time.Time `json:"expires_at"` @@ -28,12 +33,65 @@ var ( ErrInvalidLicenseSignature = errors.New("invalid license signature") ErrLicenseNotValidYet = errors.New("license not valid yet") ErrLicenseExpired = errors.New("license expired") + ErrLicenseNotApplicable = errors.New("license not applicable to this plugin") ) //go:embed offline.key var publicKey string -func ValidateLicense(logger zerolog.Logger, licenseFile string) error { +var timeFunc = time.Now + +func ValidateLicense(logger zerolog.Logger, meta plugin.Meta, licenseFileOrDirectory string) error { + fi, err := os.Stat(licenseFileOrDirectory) + if err != nil { + return err + } + if !fi.IsDir() { + return validateLicenseFile(logger, meta, licenseFileOrDirectory) + } + + found := false + var lastError error + err = filepath.WalkDir(licenseFileOrDirectory, func(path string, d os.DirEntry, err error) error { + if d.IsDir() { + if path == licenseFileOrDirectory { + return nil + } + return filepath.SkipDir + } + if err != nil { + return err + } + + if filepath.Ext(path) != ".cqlicense" { + return nil + } + + logger.Debug().Str("path", path).Msg("considering license file") + lastError = validateLicenseFile(logger, meta, path) + switch lastError { + case nil: + found = true + return filepath.SkipAll + case ErrLicenseNotApplicable: + return nil + default: + return lastError + } + }) + if err != nil { + return err + } + if found { + return nil + } + if lastError != nil { + return lastError + } + return errors.New("failed to validate license directory") +} + +func validateLicenseFile(logger zerolog.Logger, meta plugin.Meta, licenseFile string) error { licenseContents, err := os.ReadFile(licenseFile) if err != nil { return err @@ -44,6 +102,13 @@ func ValidateLicense(logger zerolog.Logger, licenseFile string) error { return err } + if len(l.Plugins) > 0 { + ref := strings.Join([]string{meta.Team, string(meta.Kind), meta.Name}, "/") + if !slices.Contains(l.Plugins, ref) { + return ErrLicenseNotApplicable + } + } + return l.IsValid(logger) } @@ -76,7 +141,7 @@ func UnpackLicense(lic []byte) (*License, error) { } func (l *License) IsValid(logger zerolog.Logger) error { - now := time.Now().UTC() + now := timeFunc().UTC() if now.Before(l.ValidFrom) { return ErrLicenseNotValidYet } diff --git a/premium/offline_test.go b/premium/offline_test.go index 92e37135c1..8b42e36182 100644 --- a/premium/offline_test.go +++ b/premium/offline_test.go @@ -1,9 +1,13 @@ package premium import ( + "os" + "path/filepath" "testing" "time" + "github.com/cloudquery/plugin-sdk/v4/plugin" + "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) @@ -25,3 +29,101 @@ func TestUnpackLicense(t *testing.T) { require.Nil(t, l) }) } + +func TestValidateLicense(t *testing.T) { + publicKey = "eacdff4866c8bc0d97de3c2d7668d0970c61aa16c3f12d6ba8083147ff92c9a6" + licData := `{"license":"eyJsaWNlbnNlZF90byI6IlVOTElDRU5TRUQgVEVTVCIsImlzc3VlZF9hdCI6IjIwMjMtMTItMjhUMTk6MDI6MjguODM4MzY3WiIsInZhbGlkX2Zyb20iOiIyMDIzLTEyLTI4VDE5OjAyOjI4LjgzODM2N1oiLCJleHBpcmVzX2F0IjoiMjAyMy0xMi0yOVQxOTowMjoyOC44MzgzNjdaIn0=","signature":"8687a858463764b052455b3c783d979d364b5fb653b86d88a7463e495480db62fdec7ae1a84d1e30dddee77eb769a0e498ecfc836538c53e410aeb1a0c04d102"}` + validTime := time.Date(2023, 12, 29, 12, 0, 0, 0, time.UTC) + expiredTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + nopMeta := plugin.Meta{Team: "cloudquery", Kind: "source", Name: "test"} + + t.Run("SingleFile", func(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "testlicense.cqlicense") + if err := os.WriteFile(f, []byte(licData), 0644); err != nil { + require.NoError(t, err) + } + + t.Run("Expired", licenseTest(f, nopMeta, expiredTime, ErrLicenseExpired)) + t.Run("Success", licenseTest(f, nopMeta, validTime, nil)) + }) + t.Run("Dir", func(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "testlicense.cqlicense") + if err := os.WriteFile(f, []byte(licData), 0644); err != nil { + require.NoError(t, err) + } + t.Run("Expired", licenseTest(dir, nopMeta, expiredTime, ErrLicenseExpired)) + t.Run("Success", licenseTest(dir, nopMeta, validTime, nil)) + }) +} + +func TestValidateSpecificLicense(t *testing.T) { + publicKey = `de452e6028fe488f56ee0dfcf5b387ee773f03d24de66f00c40ec5b17085c549` + licData := `{"license":"eyJsaWNlbnNlZF90byI6IlVOTElDRU5TRUQgVEVTVCIsInBsdWdpbnMiOlsiY2xvdWRxdWVyeS9zb3VyY2UvdGVzdDEiLCJjbG91ZHF1ZXJ5L3NvdXJjZS90ZXN0MiJdLCJpc3N1ZWRfYXQiOiIyMDI0LTAxLTAyVDExOjEwOjA5LjE0OTYwNVoiLCJ2YWxpZF9mcm9tIjoiMjAyNC0wMS0wMlQxMToxMDowOS4xNDk2MDVaIiwiZXhwaXJlc19hdCI6IjIwMjQtMDEtMDNUMTE6MTA6MDkuMTQ5NjA1WiJ9","signature":"e5752577c2b2c5a8920b3277fd11504d9c6820e8acb22bc17ccda524857c1d9fc7534f39b9a122376069ad682a2b616a10d1cfae40a984fb57fee31f13a15302"}` + validTime := time.Date(2024, 1, 2, 12, 0, 0, 0, time.UTC) + expiredTime := time.Date(2024, 1, 3, 12, 0, 0, 0, time.UTC) + invalidMeta := plugin.Meta{Team: "cloudquery", Kind: "source", Name: "test"} + validMeta := plugin.Meta{Team: "cloudquery", Kind: "source", Name: "test1"} + + t.Run("SingleFile", func(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "testlicense.cqlicense") + if err := os.WriteFile(f, []byte(licData), 0644); err != nil { + require.NoError(t, err) + } + + t.Run("Expired", licenseTest(f, validMeta, expiredTime, ErrLicenseExpired)) + t.Run("Success", licenseTest(f, validMeta, validTime, nil)) + t.Run("NotApplicable", licenseTest(f, invalidMeta, validTime, ErrLicenseNotApplicable)) + }) + t.Run("SingleDir", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "testlicense.cqlicense"), []byte(licData), 0644); err != nil { + require.NoError(t, err) + } + t.Run("Expired", licenseTest(dir, validMeta, expiredTime, ErrLicenseExpired)) + t.Run("Success", licenseTest(dir, validMeta, validTime, nil)) + t.Run("NotApplicable", licenseTest(dir, invalidMeta, validTime, ErrLicenseNotApplicable)) + }) +} + +func TestValidateSpecificLicenseMultiFile(t *testing.T) { + publicKey = `de452e6028fe488f56ee0dfcf5b387ee773f03d24de66f00c40ec5b17085c549` + licData1 := `{"license":"eyJsaWNlbnNlZF90byI6IlVOTElDRU5TRUQgVEVTVCIsInBsdWdpbnMiOlsiY2xvdWRxdWVyeS9zb3VyY2UvdGVzdDEiLCJjbG91ZHF1ZXJ5L3NvdXJjZS90ZXN0MiJdLCJpc3N1ZWRfYXQiOiIyMDI0LTAxLTAyVDExOjEwOjA5LjE0OTYwNVoiLCJ2YWxpZF9mcm9tIjoiMjAyNC0wMS0wMlQxMToxMDowOS4xNDk2MDVaIiwiZXhwaXJlc19hdCI6IjIwMjQtMDEtMDNUMTE6MTA6MDkuMTQ5NjA1WiJ9","signature":"e5752577c2b2c5a8920b3277fd11504d9c6820e8acb22bc17ccda524857c1d9fc7534f39b9a122376069ad682a2b616a10d1cfae40a984fb57fee31f13a15302"}` + licData3 := `{"license":"eyJsaWNlbnNlZF90byI6IlVOTElDRU5TRUQgVEVTVDMiLCJwbHVnaW5zIjpbImNsb3VkcXVlcnkvc291cmNlL3Rlc3QzIl0sImlzc3VlZF9hdCI6IjIwMjQtMDEtMDJUMTE6MjA6NTcuMzE2NDE0WiIsInZhbGlkX2Zyb20iOiIyMDI0LTAxLTAyVDExOjIwOjU3LjMxNjQxNFoiLCJleHBpcmVzX2F0IjoiMjAyNC0wMS0wM1QxMToyMDo1Ny4zMTY0MTRaIn0=","signature":"9be752d46010af84ec7295ede29915950dab13d4eca3b82b5645f793b39a03a6eef6bc653bee26e2a4f148b4d0fd54df6401059fda6104bc207f6dec2127850f"}` + + validTime := time.Date(2024, 1, 2, 12, 0, 0, 0, time.UTC) + expiredTime := time.Date(2024, 1, 3, 12, 0, 0, 0, time.UTC) + invalidMeta := plugin.Meta{Team: "cloudquery", Kind: "source", Name: "test"} + validMeta1 := plugin.Meta{Team: "cloudquery", Kind: "source", Name: "test1"} + validMeta3 := plugin.Meta{Team: "cloudquery", Kind: "source", Name: "test3"} + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "testlicense1.cqlicense"), []byte(licData1), 0644); err != nil { + require.NoError(t, err) + } + if err := os.WriteFile(filepath.Join(dir, "testlicense3.cqlicense"), []byte(licData3), 0644); err != nil { + require.NoError(t, err) + } + + t.Run("Expired", licenseTest(dir, validMeta1, expiredTime, ErrLicenseExpired)) + t.Run("Success", licenseTest(dir, validMeta1, validTime, nil)) + t.Run("SuccessOther", licenseTest(dir, validMeta3, validTime, nil)) + t.Run("NotApplicable", licenseTest(dir, invalidMeta, validTime, ErrLicenseNotApplicable)) +} + +func licenseTest(inputPath string, meta plugin.Meta, timeIs time.Time, expectError error) func(t *testing.T) { + return func(t *testing.T) { + timeFunc = func() time.Time { + return timeIs + } + + err := ValidateLicense(zerolog.Nop(), meta, inputPath) + if expectError == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, expectError) + } + } +} diff --git a/serve/plugin.go b/serve/plugin.go index adff0e3c82..8f2e6ed867 100644 --- a/serve/plugin.go +++ b/serve/plugin.go @@ -189,7 +189,7 @@ func (s *PluginServe) newCmdPluginServe() *cobra.Command { otel.SetTracerProvider(tp) } if licenseFile != "" { - if err := premium.ValidateLicense(logger, licenseFile); err != nil { + if err := premium.ValidateLicense(logger, s.plugin.Meta(), licenseFile); err != nil { return fmt.Errorf("failed to validate license: %w", err) } s.plugin.SetSkipUsageClient(true) @@ -303,7 +303,7 @@ func (s *PluginServe) newCmdPluginServe() *cobra.Command { cmd.Flags().StringArrayVar(&otelEndpointHeaders, "otel-endpoint-headers", []string{}, "Open Telemetry HTTP collector endpoint headers") cmd.Flags().BoolVar(&otelEndpointInsecure, "otel-endpoint-insecure", false, "use Open Telemetry HTTP endpoint (for development only)") cmd.Flags().BoolVar(&noSentry, "no-sentry", false, "disable sentry") - cmd.Flags().StringVar(&licenseFile, "license", "", "Path to offline license file") + cmd.Flags().StringVar(&licenseFile, "license", "", "Path to offline license file or directory") sendErrors := funk.ContainsString([]string{"all", "errors"}, telemetryLevel.String()) if !sendErrors { noSentry = true