Skip to content
6 changes: 6 additions & 0 deletions cmd/commands/license_grace.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,16 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o
continue
}
if !result.Valid {
if err := entitlement.RecordRejected(key); err != nil { // the cached token stays on disk: an unrecorded rejection lets offline refresh republish it
fmt.Fprintf(os.Stderr, "%s: warning: could not record the licence rejection: %v\n", masked, err)
}
doc.Keys = append(doc.Keys, licenseRefreshKey{Key: masked, Error: "invalid: " + licenseScrub(result.Message)})
fmt.Fprintf(os.Stderr, "%s: invalid — %s\n", masked, result.Message)
continue
}
if err := entitlement.RecordAccepted(key); err != nil { // a stale rejection only fails closed
fmt.Fprintf(os.Stderr, "%s: warning: could not clear the recorded licence rejection: %v\n", masked, err)
}
row := licenseRefreshKey{Key: masked, Refreshed: true, Tier: licenseScrub(result.Tier)}
if !result.ExpiresAt.IsZero() {
row.ExpiresAt = result.ExpiresAt.Format("2006-01-02")
Expand Down
45 changes: 45 additions & 0 deletions cmd/commands/license_grace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,51 @@ func TestLicenseRefreshRewritesFile(t *testing.T) {
}
}

// A scheduled refresh that the server rejects persists the rejection; a later interactive
// refresh the server accepts must lift it and rewrite the entitlement file (deterministic
// order: TestLicenseRefreshRewritesFile only hit this when its map iterated timer first).
func TestLicenseRefreshInteractiveLiftsPersistedRejection(t *testing.T) {
f := newGraceFixture(t)
timer := map[string]string{"project": f.project, "quiet": "true"}
f.mode = "revoked"
if _, _, err := runRefresh(t, f.project, timer); err == nil {
t.Fatal("a revoked key must fail the scheduled refresh")
}
f.mode = "ok"
_ = os.Remove(entitlement.Path(f.project))
if _, _, err := runRefresh(t, f.project, nil); err != nil {
t.Fatal(err)
}
if data, _ := os.ReadFile(entitlement.Path(f.project)); string(data) != f.jwt {
t.Fatalf("interactive refresh accepted by the server did not rewrite the entitlement file (%d bytes)", len(data))
}
f.mode = "ok"
if _, _, err := runRefresh(t, f.project, timer); err != nil {
t.Fatalf("the lifted rejection still blocks the scheduled refresh: %v", err)
}
}

// An interactive refresh the server answers with a signed revocation must persist the
// rejection: the cached token is untouched, so an offline refresh would otherwise republish it.
func TestLicenseRefreshInteractiveRevocationBlocksOfflineRepublish(t *testing.T) {
f := newGraceFixture(t)
if _, _, err := runRefresh(t, f.project, nil); err != nil {
t.Fatal(err)
}
f.mode = "revoked"
if _, _, err := runRefresh(t, f.project, nil); err != nil {
t.Fatal(err)
}
_ = os.Remove(entitlement.Path(f.project))
f.mode = "5xx"
if err := entitlement.Refresh(t.Context(), f.project); err == nil {
t.Fatal("an offline refresh must fail for a key the server revoked")
}
if data, err := os.ReadFile(entitlement.Path(f.project)); err == nil && len(data) > 0 { // a refusal leaves an empty file
t.Fatalf("offline refresh republished the token of a revoked key (%d bytes)", len(data))
}
}

func TestLicenseImportRewritesJWT(t *testing.T) {
f := newGraceFixture(t)
if _, _, err := runRefresh(t, f.project, nil); err != nil {
Expand Down
13 changes: 10 additions & 3 deletions internal/license/entitlement/autorefresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ func RunScheduled(ctx context.Context, projectDir string) (Outcome, error) {
}
return Outcome{Removed: res.Removed, RootRequired: res.RootRequired, Reason: reason}, nil
}
renewed, rejected, class := revalidateClassified(ctx)
renewed, rejected, class, recordErr := revalidateClassified(ctx)
if recordErr != nil { // a rejection that cannot be remembered must not publish anything
return Outcome{}, &RefreshError{Class: ClassWriteFailed, Err: recordErr}
}
if len(renewed) == 0 {
return Outcome{}, &RefreshError{Class: class}
}
Expand All @@ -242,7 +245,7 @@ func RunScheduled(ctx context.Context, projectDir string) (Outcome, error) {
}

// revalidateClassified renews every configured key once and names why nothing was renewed.
func revalidateClassified(ctx context.Context) (renewed []string, rejected map[string]bool, class string) {
func revalidateClassified(ctx context.Context) (renewed []string, rejected map[string]bool, class string, recordErr error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rejected, class = map[string]bool{}, ClassNoLicence
Expand All @@ -253,7 +256,11 @@ func revalidateClassified(ctx context.Context) (renewed []string, rejected map[s
switch {
case err == nil && explicitRejection(remote, result):
rejected[license.HashKey(key)], class = true, ClassRejected
if err := recordVerdict(license.HashKey(key), true); err != nil {
recordErr = err
}
case err == nil && result != nil && result.CanProceed && !result.FromCache && result.Status == license.StatusValid:
_ = recordVerdict(license.HashKey(key), false) // a stale rejection only fails closed
entry, _ := license.ReadCache()
token, terr := cachedToken()
switch {
Expand All @@ -270,7 +277,7 @@ func revalidateClassified(ctx context.Context) (renewed []string, rejected map[s
class = ClassPingUnavailable
}
}
return renewed, rejected, class
return renewed, rejected, class, recordErr
}

// PublishFromCache rewrites the entitlement file from the verified licence cache without
Expand Down
8 changes: 8 additions & 0 deletions internal/license/entitlement/installed.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,26 @@ func validateNewKey(ctx context.Context, projectDir, key string) error {
// 403, or a signed rejection revoke this runtime entitlement.
rejected := explicitRejection(remote, result)
if result.CanProceed && !rejected {
_ = recordVerdict(license.HashKey(key), false) // a stale rejection only fails closed
return nil
}
// FromCache alone cannot distinguish an absent cache from an auth rejection.
if !rejected {
return ErrValidationUnavailable
}
recordErr := recordVerdict(license.HashKey(key), true)
if projectDir != "" {
if writeErr := publishAfterKeyRejection(projectDir, key); writeErr != nil {
return writeErr
}
if recordErr != nil {
return errs.Wrap("E125", "the licence rejection could not be recorded and may not hold offline", recordErr)
}
return errs.New("E125", "the new licence was rejected and could not obtain a verified entitlement")
}
if recordErr != nil {
return fmt.Errorf("the licence rejection could not be recorded and may not hold offline: %w", recordErr)
}
return errors.New("the new licence was rejected and could not obtain a verified entitlement")
}

Expand Down
11 changes: 8 additions & 3 deletions internal/license/entitlement/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import (
)

// Purpose: renew configured keys once each without blocking offline starts.
// Inputs: context/keys. Outputs: rejected subjects/renewed tokens. Constraints: five seconds
// Inputs: context/keys. Outputs: rejected subjects/renewed tokens/rejection persistence error. Constraints: five seconds
// overall, existing HTTP timeouts, no cache changes on transient failures.
// SPORT: online entitlement renewal.
func revalidateKeys(ctx context.Context) (map[string]bool, []string) {
func revalidateKeys(ctx context.Context) (map[string]bool, []string, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
rejected := make(map[string]bool)
var renewed []string
var recordErr error
for _, key := range license.CollectLicenseKeys() {
if ctx.Err() != nil {
break
Expand All @@ -32,14 +33,18 @@ func revalidateKeys(ctx context.Context) (map[string]bool, []string) {
result, err := license.Validate(ctx, key, &license.ValidatorOptions{HTTPClient: remote})
if err == nil && explicitRejection(remote, result) {
rejected[license.HashKey(key)] = true
if err := recordVerdict(license.HashKey(key), true); err != nil {
recordErr = err
}
}
if err == nil && result != nil && result.CanProceed && !result.FromCache && result.Status == license.StatusValid {
_ = recordVerdict(license.HashKey(key), false) // a stale rejection only fails closed
if token, err := cachedToken(); err == nil {
renewed = append(renewed, token)
}
}
}
return rejected, renewed
return rejected, renewed, recordErr
}

// Purpose: distinguish revocation from transport and unrelated HTTP failures.
Expand Down
205 changes: 205 additions & 0 deletions internal/license/entitlement/rejected.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package entitlement

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"github.com/nself-org/cli/internal/license"
)

// rejectedFile sits beside the licence cache so a test or deployment that
// relocates the cache relocates the rejections with it.
const (
rejectedFile = "entitlement-rejected.json"
rejectedLockFile = "entitlement-rejected.lock"
markerPrefix = "entitlement-rejected-"
markerSuffix = ".mark"
)

// rejectedMu serialises this process; the flock on rejectedLockFile serialises the rest.
var rejectedMu sync.Mutex

// Purpose: locate the persisted server-rejection record.
// Inputs: none. Outputs: path/error. Constraints: same directory as the licence cache.
// SPORT: durable per-key rejection.
func rejectedPath() (string, error) {
cache, err := license.CachePath()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(cache), rejectedFile), nil
}

// Purpose: load subjects the licence server refused in an earlier run.
// Inputs: record path. Outputs: key-hash set, or an error when the record exists but
// cannot be read or parsed. Constraints: only an absent file is an empty set; an
// unreadable or corrupt record is unknown state and the caller must fail closed.
// SPORT: offline refresh must not republish a server-rejected key.
func readRejected(path string) (map[string]bool, error) {
set := make(map[string]bool)
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return set, nil
}
if err != nil {
return nil, fmt.Errorf("read rejection record: %w", err)
}
var hashes []string
if err := json.Unmarshal(data, &hashes); err != nil {
return nil, fmt.Errorf("rejection record is corrupt: %w", err)
}
for _, h := range hashes {
set[h] = true
}
return set, nil
}

// Purpose: load the persisted rejections for key selection.
// Inputs: none. Outputs: key-hash set or error. Constraints: see readRejected.
// SPORT: offline refresh must not republish a server-rejected key.
func loadRejected() (map[string]bool, error) {
path, err := rejectedPath()
if err != nil {
return nil, err
}
set, err := readRejected(path)
if err != nil {
return nil, err
}
// Marker files are the lock-free fallback written when the record could not be updated.
// Enumerate literally (no glob: a cache path holding [ or * would match nothing) and
// fail closed when the directory cannot be listed.
entries, err := os.ReadDir(filepath.Dir(path))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("list rejection markers: %w", err)
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || len(name) <= len(markerPrefix)+len(markerSuffix) ||
!strings.HasPrefix(name, markerPrefix) || !strings.HasSuffix(name, markerSuffix) {
continue
}
set[name[len(markerPrefix):len(name)-len(markerSuffix)]] = true
}
return set, nil
}

// Purpose: remember a rejection when the shared record cannot be updated (lock or
// record unwritable). Inputs: key hash, record path. Outputs: persistence error.
// Constraints: one file per key, no read-modify-write, no lock; loadRejected unions
// the markers, so a failed record update cannot revive the rejected key offline.
// SPORT: durable per-key rejection.
func writeMarker(path, hash string) error {
f, err := os.OpenFile(filepath.Join(filepath.Dir(path), markerPrefix+hash+markerSuffix), os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
return f.Close()
}

// Purpose: persist a server verdict for one configured key.
// Inputs: key hash and whether the server refused it. Outputs: persistence error.
// Constraints: the whole read-modify-write runs under a process mutex and a file
// lock, so concurrent refreshes never drop each other's rejections. Only an explicit
// refusal adds, only a fresh online acceptance clears. A corrupt record is unknown
// state: it is rebuilt with every configured key rejected, so only a fresh acceptance
// of a key lifts it. When the record cannot be updated, a rejection falls back to a
// per-key marker; an error is returned only if the key is not remembered at all.
// SPORT: durable per-key rejection.
func recordVerdict(hash string, rejected bool) error {
path, err := rejectedPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
if !rejected {
_ = os.Remove(filepath.Join(filepath.Dir(path), markerPrefix+hash+markerSuffix))
}
err = updateRecord(path, hash, rejected)
if err != nil && rejected {
if merr := writeMarker(path, hash); merr == nil {
return nil
}
}
return err
}

// Purpose: lift a persisted rejection after the server freshly accepted the key outside
// the scheduled path (interactive `license refresh`). Inputs: the licence key.
// Outputs: persistence error. Constraints: only call on a live, signed acceptance; a
// failure leaves the rejection in place, which only fails closed.
// SPORT: durable per-key rejection.
func RecordAccepted(key string) error {
return recordVerdict(license.HashKey(key), false)
}

// Purpose: persist a signed server refusal seen outside the scheduled path (interactive
// `license refresh`). Inputs: the licence key. Outputs: persistence error. Constraints: only
// call on an authenticated valid:false reply; the cached token stays on disk, so without this
// a later offline refresh would republish it.
// SPORT: durable per-key rejection.
func RecordRejected(key string) error {
return recordVerdict(license.HashKey(key), true)
}

// Purpose: read-modify-write the shared record under the process mutex and file lock.
// Inputs: record path, key hash, verdict. Outputs: persistence error.
// SPORT: durable per-key rejection.
func updateRecord(path, hash string, rejected bool) error {
rejectedMu.Lock()
defer rejectedMu.Unlock()
lock, err := os.OpenFile(filepath.Join(filepath.Dir(path), rejectedLockFile), os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return err
}
defer func() { _ = lock.Close() }()
if err := lockRejected(lock); err != nil {
return err
}
set, readErr := readRejected(path)
if readErr != nil {
set = make(map[string]bool)
for _, key := range license.CollectLicenseKeys() {
set[license.HashKey(key)] = true
}
} else if set[hash] == rejected {
return nil
}
if rejected {
set[hash] = true
} else {
delete(set, hash)
}
hashes := make([]string, 0, len(set))
for h := range set {
hashes = append(hashes, h)
}
data, err := json.Marshal(hashes)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".entitlement-rejected-*")
if err != nil {
return err
}
defer func() { _ = os.Remove(tmp.Name()) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(0600); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
10 changes: 10 additions & 0 deletions internal/license/entitlement/rejected_lock_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !unix

package entitlement

import "os"

// Purpose: the rejection lock on platforms without flock (Windows).
// Constraints: the process-wide mutex still serialises one process's writers;
// Windows runs the unix build under WSL2 (VMI).
func lockRejected(*os.File) error { return nil }
Loading
Loading