From df9aafa3230a1eb7914c3860e867f0c19d3cdde2 Mon Sep 17 00:00:00 2001 From: commanderxgr Date: Sat, 5 Sep 2026 02:03:27 +0300 Subject: [PATCH] feat(phase7): implement server-trusted entitlement resolver foundation and usage spec --- docs/PHASE_7_ENTITLEMENTS_AND_USAGE_SPEC.md | 124 ++++++++++++ .../src/entitlements/entitlementResolver.js | 181 ++++++++++++++++++ package.json | 3 +- scripts/test-entitlements.mjs | 141 ++++++++++++++ 4 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 docs/PHASE_7_ENTITLEMENTS_AND_USAGE_SPEC.md create mode 100644 functions/src/entitlements/entitlementResolver.js create mode 100644 scripts/test-entitlements.mjs diff --git a/docs/PHASE_7_ENTITLEMENTS_AND_USAGE_SPEC.md b/docs/PHASE_7_ENTITLEMENTS_AND_USAGE_SPEC.md new file mode 100644 index 0000000..98b787c --- /dev/null +++ b/docs/PHASE_7_ENTITLEMENTS_AND_USAGE_SPEC.md @@ -0,0 +1,124 @@ +# ShiftOryx Phase 7 — Subscriptions, Entitlements & Daily Usage Architecture Specification + +**Status:** `FOUNDATION_IMPLEMENTED_PENDING_BILLING_INTEGRATION` +**Milestone:** Phase 7 — Multi-Tenant Subscriptions & Server-Enforced Entitlements +**Baseline Git Commit:** `25be00cec0ecf715ddd97cf162868a299f100472` +**Branch:** `antigravity/phase7-entitlements-foundation` + +--- + +## 1. Executive Summary + +Phase 7 designs and implements the server-trusted subscription, entitlement, and daily usage accounting architecture for ShiftOryx. In multi-tenant SaaS, access control and plan limits cannot rely on client-side feature flags or unvalidated client Firestore writes. This specification establishes: + +1. **A pure, fail-safe Entitlement Resolver** (`functions/src/entitlements/entitlementResolver.js`). +2. **A server-authoritative Data Model** for tenant subscriptions and idempotent daily usage counters. +3. **A comprehensive Threat Model** securing subscriptions against tenant privilege escalation and counter tampering. +4. **Proposed Firestore Security Rules** enforcing zero-write client access on subscription and usage documents. + +--- + +## 2. Entitlement Threat Model + +### 2.1 Trust Boundaries +- **Client (Browser):** Untrusted. Can inspect local storage, inspect bundle code, and spoof state. Client-side feature flags (`isFeatureEntitled`) are for UX gating (hiding buttons, displaying upgrade prompts) only. +- **Tenant OWNER:** Semi-trusted. Has administrative permissions within their own tenant workspace (`tenants/{tenantId}`), but MUST NOT have write permissions to their own subscription, plan limits, or usage meters. +- **Platform Admin:** Trusted for platform governance, but platform admin status does **not** automatically grant a paid subscription or bypass usage limits without explicit assignment. +- **Server (Cloud Functions / Admin SDK):** Fully trusted. Authoritative source of subscription state, payment webhook reconciliation, and transactional usage increments. + +### 2.2 Security Invariants +1. **Zero Client Writes on Subscriptions:** Tenants cannot create, update, or delete `tenants/{tenantId}/subscription/{subId}`. +2. **Zero Client Writes on Daily Usage:** Tenants cannot manipulate or overwrite usage counters in `usage/daily/{tenantId}:{date}`. +3. **Fail-Closed on Unknowns:** Missing subscription, expired dates, unrecognized plans, or inactive/suspended tenants immediately resolve to `valid: false` with zero entitlements. +4. **Timezone Determinism:** Daily usage meters are normalized to tenant-configured local timezone (defaulting to `Europe/Athens` / `UTC+2/UTC+3`) rather than client device clock. + +--- + +## 3. Authoritative Data Model + +### 3.1 Tenant Subscription Model +Path: `tenants/{tenantId}/subscription/current` (or dedicated collection `subscriptions/{tenantId}`) + +```typescript +interface TenantSubscription { + tenantId: string; // Bound tenant ID + planId: 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE' | 'PILOT_FREE'; + status: 'ACTIVE' | 'TRIALING' | 'PAST_DUE' | 'CANCELED' | 'SUSPENDED'; + billingCycle: 'MONTHLY' | 'ANNUAL'; + effectiveAtMs: number; // Timestamp when access begins + expiresAtMs: number | null; // Timestamp when access expires (null if auto-renewing) + cancelAtPeriodEnd: boolean; + externalCustomerId?: string; // e.g. Stripe Customer ID (server only) + externalSubscriptionId?: string; // e.g. Stripe Subscription ID (server only) + limitOverrides?: { // Custom negotiated limits + maxEmployees?: number; + maxWorkspaces?: number; + maxMonthlyPdfExports?: number; + enableAdvancedSolver?: boolean; + enableMultiWindow?: boolean; + enableAuditLogs?: boolean; + }; + version: number; // Optimistic concurrency counter + updatedAt: string; // ISO 8601 server timestamp + updatedBy: string; // 'system' | 'stripe-webhook' | platformAdminUid +} +``` + +### 3.2 Daily Usage Counter Model +Path: `usageDaily/{tenantId_YYYYMMDD}` + +```typescript +interface DailyUsageRecord { + id: string; // Format: `${tenantId}_${yyyy-mm-dd}` + tenantId: string; + date: string; // ISO date string: YYYY-MM-DD + timezone: string; // e.g. "Europe/Athens" + metrics: { + scheduleGenerationRuns: number; // Count of AI/solver generation runs + pdfExportCount: number; // Count of published PDF exports + activeSmsNotifications?: number; // Count of external notifications + }; + idempotencyKeys: string[]; // Last N processed transaction keys to prevent duplicate increments + updatedAt: string; // ISO 8601 timestamp +} +``` + +--- + +## 4. Idempotent Usage Accounting Design + +To prevent contention, race conditions, and duplicate usage charges during schedule generation or exports: +1. Every usage event generates a deterministic client request UUID: `requestId`. +2. Cloud Functions execute a Firestore `runTransaction`: + - Reads `usageDaily/{tenantId_date}`. + - If `requestId` exists in `idempotencyKeys`, the write is a no-op (idempotent return). + - If not present, increments the metric and appends `requestId` to `idempotencyKeys` (capped at 100 entries). +3. If the metric exceeds the daily or monthly entitlement limit, the Cloud Function rejects the operation before performing expensive backend work. + +--- + +## 5. Proposed Firestore Security Rules Changes + +```text +// Server-only subscription documents (readable by Tenant Admin, zero client writes) +match /tenants/{tenantId}/subscription/{subId} { + allow read: if isTenantAdmin(tenantId); + allow write: if false; // Only Cloud Functions / Admin SDK +} + +// Server-only daily usage accounting (readable by Tenant Admin, zero client writes) +match /usageDaily/{usageId} { + allow read: if isSignedIn() && ( + isPlatformAdmin() || + (resource.data.tenantId is string && isTenantAdmin(resource.data.tenantId)) + ); + allow write: if false; // Only Cloud Functions / Admin SDK +} +``` + +--- + +## 6. Implementation Status & Next Steps + +- **Completed:** Pure entitlement resolver foundation (`functions/src/entitlements/entitlementResolver.js`) with 10 passing unit tests covering all edge cases. +- **Pending (Phase 7 Roadmapped):** Stripe Webhook endpoint, payment reconciliation Cloud Functions, and UI billing management screens. diff --git a/functions/src/entitlements/entitlementResolver.js b/functions/src/entitlements/entitlementResolver.js new file mode 100644 index 0000000..4ad6b1b --- /dev/null +++ b/functions/src/entitlements/entitlementResolver.js @@ -0,0 +1,181 @@ +/** + * ShiftOryx Phase 7 — Pure Server-Trusted Entitlement Resolver Foundation + * + * Designed for server-side authority (Cloud Functions, Security Rules, and Client projection). + * Fails closed on missing, malformed, expired, or suspended state. + */ + +export const SUBSCRIPTION_STATUS = { + active: 'ACTIVE', + trialing: 'TRIALING', + pastDue: 'PAST_DUE', + canceled: 'CANCELED', + suspended: 'SUSPENDED', +}; + +export const PLANS = { + starter: 'STARTER', + professional: 'PROFESSIONAL', + enterprise: 'ENTERPRISE', + pilot: 'PILOT_FREE', +}; + +export const DEFAULT_PLAN_LIMITS = { + [PLANS.pilot]: { + maxEmployees: 15, + maxWorkspaces: 1, + maxMonthlyPdfExports: 10, + enableAdvancedSolver: true, + enableMultiWindow: true, + enableAuditLogs: true, + }, + [PLANS.starter]: { + maxEmployees: 10, + maxWorkspaces: 1, + maxMonthlyPdfExports: 5, + enableAdvancedSolver: false, + enableMultiWindow: false, + enableAuditLogs: false, + }, + [PLANS.professional]: { + maxEmployees: 35, + maxWorkspaces: 3, + maxMonthlyPdfExports: 50, + enableAdvancedSolver: true, + enableMultiWindow: true, + enableAuditLogs: true, + }, + [PLANS.enterprise]: { + maxEmployees: 250, + maxWorkspaces: 20, + maxMonthlyPdfExports: 1000, + enableAdvancedSolver: true, + enableMultiWindow: true, + enableAuditLogs: true, + }, +}; + +const FAIL_CLOSED_ENTITLEMENTS = { + valid: false, + reason: 'invalid-or-suspended-subscription', + planId: null, + status: SUBSCRIPTION_STATUS.suspended, + limits: { + maxEmployees: 0, + maxWorkspaces: 0, + maxMonthlyPdfExports: 0, + enableAdvancedSolver: false, + enableMultiWindow: false, + enableAuditLogs: false, + }, +}; + +/** + * Pure function to resolve and normalize tenant entitlements. + * + * @param {Object} params + * @param {Object} params.tenant Tenant document data + * @param {Object} [params.subscription] Subscription document data + * @param {number|Date} [params.now] Current timestamp (ms or Date) + * @returns {Object} Normalized entitlement object + */ +export function resolveTenantEntitlements({ tenant, subscription, now = Date.now() }) { + const currentTimestamp = typeof now === 'number' ? now : now instanceof Date ? now.getTime() : Date.now(); + + // 1. Tenant must exist and be ACTIVE + if (!tenant || tenant.status !== 'ACTIVE') { + return { + ...FAIL_CLOSED_ENTITLEMENTS, + reason: !tenant ? 'tenant-not-found' : `tenant-status-${tenant.status || 'unknown'}`, + }; + } + + // 2. If subscription object is missing, check if tenant has legacy/pilot bypass flag + if (!subscription) { + if (tenant.tier === 'PILOT' || tenant.planId === PLANS.pilot) { + return { + valid: true, + reason: 'pilot-complimentary-grant', + planId: PLANS.pilot, + status: SUBSCRIPTION_STATUS.active, + limits: { ...DEFAULT_PLAN_LIMITS[PLANS.pilot] }, + effectiveAt: tenant.createdAt || null, + expiresAt: null, + }; + } + return { + ...FAIL_CLOSED_ENTITLEMENTS, + reason: 'missing-subscription', + }; + } + + // 3. Subscription status validation + const status = String(subscription.status || '').toUpperCase(); + if (status !== SUBSCRIPTION_STATUS.active && status !== SUBSCRIPTION_STATUS.trialing) { + return { + ...FAIL_CLOSED_ENTITLEMENTS, + status, + reason: `subscription-${status.toLowerCase() || 'inactive'}`, + }; + } + + // 4. Time boundaries (effectiveAt & expiresAt) + const effectiveAtMs = subscription.effectiveAtMs ?? (subscription.effectiveAt ? new Date(subscription.effectiveAt).getTime() : null); + const expiresAtMs = subscription.expiresAtMs ?? (subscription.expiresAt ? new Date(subscription.expiresAt).getTime() : null); + + if (effectiveAtMs && !Number.isNaN(effectiveAtMs) && currentTimestamp < effectiveAtMs) { + return { + ...FAIL_CLOSED_ENTITLEMENTS, + reason: 'subscription-not-yet-effective', + }; + } + + if (expiresAtMs && !Number.isNaN(expiresAtMs) && currentTimestamp > expiresAtMs) { + return { + ...FAIL_CLOSED_ENTITLEMENTS, + reason: 'subscription-expired', + }; + } + + // 5. Plan recognition + const planId = String(subscription.planId || '').toUpperCase(); + const baseLimits = DEFAULT_PLAN_LIMITS[planId]; + if (!baseLimits) { + return { + ...FAIL_CLOSED_ENTITLEMENTS, + reason: `unknown-plan-${planId}`, + }; + } + + // 6. Custom limit overrides from subscription document (if server configured) + const limits = { + ...baseLimits, + ...(subscription.limitOverrides || {}), + }; + + return { + valid: true, + reason: 'subscription-active', + planId, + status, + limits, + effectiveAtMs, + expiresAtMs, + }; +} + +/** + * Checks whether an employee creation or count is within the tenant's entitlements. + */ +export function canAddEmployee({ activeEmployeeCount, entitlements }) { + if (!entitlements || !entitlements.valid) return false; + return activeEmployeeCount < (entitlements.limits?.maxEmployees ?? 0); +} + +/** + * Checks whether a feature flag is entitled. + */ +export function isFeatureEntitled(featureName, entitlements) { + if (!entitlements || !entitlements.valid) return false; + return Boolean(entitlements.limits?.[featureName]); +} diff --git a/package.json b/package.json index bc76483..b7ff4e2 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "security:integrity": "node scripts/validate-firestore-integrity.mjs", "security:hardening": "node scripts/validate-security-hardening.mjs", "security:scan": "npm run security:hardening && npm run security:integrity && npm run security:audit && npm run security:cve", - "qa:central-portal-isolation": "node scripts/validate-central-portal-isolation.mjs" + "qa:central-portal-isolation": "node scripts/validate-central-portal-isolation.mjs", + "test:entitlements": "node scripts/test-entitlements.mjs" }, "dependencies": { "@dnd-kit/core": "^6.2.0", diff --git a/scripts/test-entitlements.mjs b/scripts/test-entitlements.mjs new file mode 100644 index 0000000..1494466 --- /dev/null +++ b/scripts/test-entitlements.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { + canAddEmployee, + DEFAULT_PLAN_LIMITS, + isFeatureEntitled, + PLANS, + resolveTenantEntitlements, + SUBSCRIPTION_STATUS, +} from '../functions/src/entitlements/entitlementResolver.js'; + +console.log('--- RUNNING PHASE 7 ENTITLEMENT RESOLVER TEST SUITE ---'); + +let testsCount = 0; +function pass() { + testsCount++; +} + +const activeTenant = { id: 'tenant-1', status: 'ACTIVE', slug: 'tenant-1' }; +const suspendedTenant = { id: 'tenant-2', status: 'SUSPENDED', slug: 'tenant-2' }; + +// 1. Missing or inactive tenant fails closed +const missingTenant = resolveTenantEntitlements({ tenant: null, subscription: { status: 'ACTIVE', planId: PLANS.starter } }); +assert.equal(missingTenant.valid, false); +assert.equal(missingTenant.reason, 'tenant-not-found'); +pass(); + +const suspendedRes = resolveTenantEntitlements({ tenant: suspendedTenant, subscription: { status: 'ACTIVE', planId: PLANS.starter } }); +assert.equal(suspendedRes.valid, false); +assert.equal(suspendedRes.reason, 'tenant-status-SUSPENDED'); +pass(); + +// 2. Active subscription resolves correctly +const now = Date.now(); +const activeStarter = resolveTenantEntitlements({ + tenant: activeTenant, + subscription: { + planId: PLANS.starter, + status: SUBSCRIPTION_STATUS.active, + effectiveAtMs: now - 10_000, + expiresAtMs: now + 86_400_000, + }, + now, +}); +assert.equal(activeStarter.valid, true); +assert.equal(activeStarter.planId, PLANS.starter); +assert.equal(activeStarter.limits.maxEmployees, DEFAULT_PLAN_LIMITS[PLANS.starter].maxEmployees); +assert.equal(activeStarter.limits.enableAdvancedSolver, false); +pass(); + +// 3. Trialing subscription resolves correctly +const trialingPro = resolveTenantEntitlements({ + tenant: activeTenant, + subscription: { + planId: PLANS.professional, + status: SUBSCRIPTION_STATUS.trialing, + expiresAtMs: now + 7 * 86_400_000, + }, + now, +}); +assert.equal(trialingPro.valid, true); +assert.equal(trialingPro.status, SUBSCRIPTION_STATUS.trialing); +assert.equal(trialingPro.limits.maxEmployees, DEFAULT_PLAN_LIMITS[PLANS.professional].maxEmployees); +assert.equal(trialingPro.limits.enableAdvancedSolver, true); +pass(); + +// 4. Expired subscription fails closed +const expiredSub = resolveTenantEntitlements({ + tenant: activeTenant, + subscription: { + planId: PLANS.starter, + status: SUBSCRIPTION_STATUS.active, + expiresAtMs: now - 1000, + }, + now, +}); +assert.equal(expiredSub.valid, false); +assert.equal(expiredSub.reason, 'subscription-expired'); +pass(); + +// 5. Future subscription not yet effective fails closed +const futureSub = resolveTenantEntitlements({ + tenant: activeTenant, + subscription: { + planId: PLANS.starter, + status: SUBSCRIPTION_STATUS.active, + effectiveAtMs: now + 50_000, + }, + now, +}); +assert.equal(futureSub.valid, false); +assert.equal(futureSub.reason, 'subscription-not-yet-effective'); +pass(); + +// 6. Unknown plan fails closed +const unknownPlan = resolveTenantEntitlements({ + tenant: activeTenant, + subscription: { + planId: 'MAGIC_SUPER_UNLIMITED', + status: SUBSCRIPTION_STATUS.active, + }, + now, +}); +assert.equal(unknownPlan.valid, false); +assert.equal(unknownPlan.reason, 'unknown-plan-MAGIC_SUPER_UNLIMITED'); +pass(); + +// 7. Pilot complimentary fallback +const pilotTenant = { id: 'bp-kallis', status: 'ACTIVE', tier: 'PILOT' }; +const pilotGrant = resolveTenantEntitlements({ tenant: pilotTenant, subscription: null }); +assert.equal(pilotGrant.valid, true); +assert.equal(pilotGrant.planId, PLANS.pilot); +assert.equal(pilotGrant.limits.maxEmployees, 15); +pass(); + +// 8. Custom limit overrides +const customSub = resolveTenantEntitlements({ + tenant: activeTenant, + subscription: { + planId: PLANS.starter, + status: SUBSCRIPTION_STATUS.active, + limitOverrides: { + maxEmployees: 22, + }, + }, + now, +}); +assert.equal(customSub.valid, true); +assert.equal(customSub.limits.maxEmployees, 22); +pass(); + +// 9. canAddEmployee and isFeatureEntitled helpers +assert.equal(canAddEmployee({ activeEmployeeCount: 9, entitlements: activeStarter }), true); +assert.equal(canAddEmployee({ activeEmployeeCount: 10, entitlements: activeStarter }), false); +assert.equal(canAddEmployee({ activeEmployeeCount: 5, entitlements: expiredSub }), false); + +assert.equal(isFeatureEntitled('enableAdvancedSolver', activeStarter), false); +assert.equal(isFeatureEntitled('enableAdvancedSolver', trialingPro), true); +assert.equal(isFeatureEntitled('enableAdvancedSolver', expiredSub), false); +pass(); + +console.log(`\n--- ALL ${testsCount} ENTITLEMENT RESOLVER TESTS PASSED ---`);