diff --git a/CHANGELOG.md b/CHANGELOG.md index f887cba30..1a18db230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Decoupled offline-license anonymous access from the seat cap. [#1349](https://github.com/sourcebot-dev/sourcebot/pull/1349) + ### Added - Recorded service ping history locally and added a "Download usage report" button to the offline license settings page, so offline deployments can export their usage and send it to us. [#1348](https://github.com/sourcebot-dev/sourcebot/pull/1348) diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts index 35fe4a65d..89b1bcfbe 100644 --- a/packages/shared/src/entitlements.test.ts +++ b/packages/shared/src/entitlements.test.ts @@ -40,11 +40,12 @@ const encodeOfflineKey = (payload: object): string => { const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString(); const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(); -const validOfflineKey = (overrides: { seats?: number; expiryDate?: string } = {}) => +const validOfflineKey = (overrides: { seats?: number; anonymousAccess?: boolean; expiryDate?: string } = {}) => encodeOfflineKey({ id: 'test-customer', expiryDate: overrides.expiryDate ?? futureDate, ...(overrides.seats !== undefined ? { seats: overrides.seats } : {}), + ...(overrides.anonymousAccess !== undefined ? { anonymousAccess: overrides.anonymousAccess } : {}), sig: 'fake-sig', }); @@ -112,23 +113,35 @@ describe('isAnonymousAccessAvailable', () => { }); describe('with an offline license key', () => { - test('returns false when offline key has a seat count', () => { + test('returns false when offline key does not grant anonymous access', () => { mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); expect(isAnonymousAccessAvailable(null)).toBe(false); }); - test('returns true when offline key has no seat count (unlimited)', () => { + test('returns false when offline key is uncapped but does not grant anonymous access', () => { + // Uncapped (no seats) no longer implies anonymous access — it must + // be granted explicitly via the `anonymousAccess` flag. mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + expect(isAnonymousAccessAvailable(null)).toBe(false); + }); + + test('returns true when offline key explicitly grants anonymous access', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true }); expect(isAnonymousAccessAvailable(null)).toBe(true); }); - test('unlimited offline key beats an active online license', () => { - mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + test('anonymous access is independent of the seat cap', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, anonymousAccess: true }); + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('anonymous-access offline key beats an active online license', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true }); expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(true); }); test('falls through to online license check when offline key is expired', () => { - mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, expiryDate: pastDate }); + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true, expiryDate: pastDate }); expect(isAnonymousAccessAvailable(null)).toBe(true); expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(false); }); @@ -144,7 +157,7 @@ describe('isAnonymousAccessAvailable', () => { }); test('falls through when offline key signature is invalid', () => { - mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true }); mocks.verifySignature.mockReturnValue(false); expect(isAnonymousAccessAvailable(null)).toBe(true); }); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 93595f125..c03b2f56e 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -12,6 +12,8 @@ const offlineLicensePrefix = "sourcebot_ee_"; const offlineLicensePayloadSchema = z.object({ id: z.string(), seats: z.number().optional(), + // Whether anonymous (unauthenticated) access is permitted. + anonymousAccess: z.boolean().optional(), // ISO 8601 date string expiryDate: z.string().datetime(), sig: z.string(), @@ -50,7 +52,13 @@ const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense const payloadJson = JSON.parse(decodedPayload); const licenseData = offlineLicensePayloadSchema.parse(payloadJson); + // Keys are listed alphabetically to match the canonical JSON the + // signer produces (Python `json.dumps(..., sort_keys=True)`). + // `JSON.stringify` drops `undefined` values, so omitted optional + // fields (e.g. a legacy key without `anonymousAccess`) verify exactly + // as they were originally signed. const dataToVerify = JSON.stringify({ + anonymousAccess: licenseData.anonymousAccess, expiryDate: licenseData.expiryDate, id: licenseData.id, seats: licenseData.seats @@ -138,7 +146,7 @@ export const isValidLicenseActive = (_license: License | null): boolean => { export const isAnonymousAccessAvailable = (_license: License | null): boolean => { const offlineKey = getValidOfflineLicense(); if (offlineKey) { - return offlineKey.seats === undefined; + return offlineKey.anonymousAccess === true; } const onlineLicense = getValidOnlineLicense(_license); @@ -171,6 +179,7 @@ export const hasEntitlement = (entitlement: Entitlement, _license: License | nul export type OfflineLicenseMetadata = { id: string; seats?: number; + anonymousAccess?: boolean; expiryDate: string; } @@ -186,6 +195,7 @@ export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => { return { id: license.id, seats: license.seats, + anonymousAccess: license.anonymousAccess, expiryDate: license.expiryDate, }; }