Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions docs/PHASE_7_ENTITLEMENTS_AND_USAGE_SPEC.md
Original file line number Diff line number Diff line change
@@ -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.
181 changes: 181 additions & 0 deletions functions/src/entitlements/entitlementResolver.js
Original file line number Diff line number Diff line change
@@ -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]);
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading