license — Enterprise license validation and feature gating for cloud deployments
This module provides cryptographic validation of signed license keys, caching of license state, and FastAPI dependency injection hooks to gate enterprise features. It exists to enforce licensing policies at runtime while maintaining a clean separation between licensing logic and business logic, enabling PocketPaw to support both open-source and commercial deployment models.
Categories: licensing & commercialization, authorization & access control, FastAPI integration, security & cryptography
Concepts: LicensePayload, LicenseInfo, Ed25519 cryptography, HMAC-SHA256 fallback, FastAPI dependency injection, Depends(), HTTPException, require_license, require_feature, get_license_info
Words: 1795 | Version: 1
Purpose
The license module is the runtime enforcement layer for PocketPaw’s enterprise licensing system. It solves two problems:
- Verification: Ensure that license keys provided at deployment time are authentic (signed by the license server) and valid (not expired, issued to a legitimate org).
- Authorization: Gate access to premium features at the HTTP endpoint level using FastAPI’s dependency injection system, preventing unlicensed deployments from accessing enterprise functionality.
This module exists as a separate concern because licensing is orthogonal to core business logic—a user management service shouldn’t need to know about license states. By centralizing this here, the system can:
- Use Ed25519 cryptography to verify license authenticity without storing the private key in the codebase
- Support multiple deployment models: self-hosted (HMAC-SHA256 fallback), cloud (Ed25519 verification), and open-source (no license required, endpoints return 403)
- Cache the license on first load to avoid repeated disk/env lookups
- Provide a single source of truth for license state across all endpoints
Key Classes and Methods
LicensePayload(BaseModel)
The data model representing the contents of a valid license key. It holds:
org(str): Organization identifier (e.g., “acme-inc”), used for audit logging and multi-tenancyplan(str): License tier—“team” (default, 5 seats), “business”, or “enterprise”seats(int): Number of concurrent users allowed (default 5)exp(str): Expiration date in ISO format (e.g., “2027-01-01”)features(list[str]): Optional feature flags (e.g., [“analytics”, “sso”])
Key properties:
expired(property): Returns True if current UTC time > expiration date. Handles date parsing errors gracefully by returning True (fail-safe: invalid dates are treated as expired).has_feature(feature: str)(method): Returns True if the feature is in the features list OR the plan is “enterprise” (enterprise always unlocks all features). This implements the business rule that enterprise licenses are feature-complete.
_verify_signature(payload_bytes: bytes, signature_hex: str) -> bool
Cryptographic validation function with a fallback chain:
- Primary (Ed25519): If
POCKETPAW_LICENSE_PUBLIC_KEYis set, verify the signature using the public key embedded in the code. This is the secure path for cloud deployments. - Fallback (HMAC-SHA256): If no public key is configured, compute
SHA256("<secret>:<payload>")and compare. This allows self-hosted deployments to use a simpler symmetric key model without managing keypairs. - Reject: If neither key is available, return False (fail-safe).
The function catches all exceptions (malformed hex, cryptography library errors) and returns False, preventing crashes from bad input.
validate_license_key(key: str) -> LicensePayload
The main parsing and validation entry point. It:
- Base64-decodes the license key string
- Splits on the last ”.” to separate payload from signature
- Verifies the signature cryptographically
- JSON-deserializes the payload into a
LicensePayloadobject - Checks expiration
- Raises
ValueErrorwith a specific message if any step fails
This is the only function that parses untrusted input, so all validation is concentrated here.
load_license() -> LicensePayload | None
Startup-time license loader:
- Returns cached license if already loaded (prevents re-parsing)
- Attempts to load
.envfile (viadotenv) if available - Reads
POCKETPAW_LICENSE_KEYfrom environment - Calls
validate_license_key()and caches the result - Returns None if key is missing or invalid, storing the error reason in
_license_errorfor later reporting - Logs success/failure at WARNING level so operators see licensing status in startup output
This is called during app initialization (via FastAPI startup hooks or explicit imports).
get_license() -> LicensePayload | None
Lazy loader and cache getter. Returns the cached license if available; otherwise calls load_license(). This is safe to call on every request because the cache prevents repeated parsing.
async require_license() -> LicensePayload
A FastAPI dependency that gates endpoints behind a valid license:
@app.get("/api/enterprise/thing")async def get_thing(license: LicensePayload = Depends(require_license)): # Only reachable if license is valid and not expired ...Raises HTTPException(403) with a descriptive error message if:
- License is None (not configured)
- License is expired
The error message includes the stored license error (e.g., “Invalid signature”) so operators can debug configuration issues.
require_feature(feature: str)
A dependency factory that returns a specialized dependency for per-feature gating:
@app.get("/api/sso/config")async def get_sso_config(license: LicensePayload = Depends(require_feature("sso"))): # Only reachable if license exists, isn't expired, AND includes "sso" feature ...Composed as: calls require_license() (ensures a valid license exists), then checks license.has_feature(feature). Raises HTTPException(403) with the plan name if the feature is not included.
LicenseInfo(BaseModel) & get_license_info() -> LicenseInfo
A read-only view of license state for the settings/admin UI:
valid(bool): True if license exists and is not expiredorg,plan,seats,exp(optional): Populated from the license payloaderror(optional): Human-readable error message (e.g., “License expired”, “Invalid signature”)
get_license_info() always returns a LicenseInfo object (never raises), making it safe to expose via a public endpoint for UI rendering.
How It Works
Initialization Flow
- App startup: The FastAPI app imports this module (or explicitly calls
load_license()) load_license()reads the environment variable and validates the key- The
LicensePayloadis cached in_cached_licenseand the app continues normally - If validation fails,
_license_erroris set and subsequent license checks return None
Request-Time License Check
- A client hits an endpoint decorated with
@Depends(require_license)or@Depends(require_feature(...)) - FastAPI calls the dependency function
- The dependency calls
get_license(), which returns the cachedLicensePayload(fast path) or None - If None, an HTTPException(403) is raised; FastAPI returns a 403 response to the client
- If valid, the endpoint handler receives the license as an argument and proceeds
Key Data Flow
POCKETPAW_LICENSE_KEY (env var) ↓validate_license_key() ├─ base64 decode ├─ split on "." ├─ _verify_signature() → cryptographic check └─ JSON deserialize → LicensePayload ↓_cached_license ↓get_license() → (used by endpoints) ↓require_license() [FastAPI dependency] ↓HTTPException(403) or endpoint handlerEdge Cases
- Missing public key: If
POCKETPAW_LICENSE_PUBLIC_KEYis not set, the system falls back to HMAC-SHA256. This allows self-hosted installations to validate licenses without managing asymmetric keys. - Unparseable dates: If the
expfield cannot be parsed as an ISO date,expiredreturns True (fail-safe: invalid licenses are treated as expired). - Missing .env file: The code attempts to load
.envviapython-dotenv, but ignores ImportError if the library isn’t installed. This allows the module to work in environments where.envfiles aren’t used. - Expired enterprise key with no public key: If the key format is invalid but
POCKETPAW_LICENSE_SECRETis set, the signature check may pass, but the expiration check still fails. - Concurrent requests: The cache is not thread-locked, but loading the license twice is idempotent and safe (parsing the same environment variable twice yields the same result).
Authorization and Security
Cryptographic Security
- Production (cloud): License keys are signed with Ed25519 (NIST-recommended, post-quantum resistant). The public key is embedded in this file; the private key exists only on the license server. An attacker cannot forge a license without the private key.
- Self-hosted fallback: Uses HMAC-SHA256 with a shared secret (
POCKETPAW_LICENSE_SECRET). The secret must be provisioned out-of-band and kept confidential. HMAC is vulnerable to brute-force but acceptable for internal deployments. - No license: If neither key is configured, all signature checks fail. Deployments without licensing can run open-source features but cannot access enterprise endpoints.
Access Control
Two layers of gating:
require_license(): Requires a valid, non-expired license. Permits any plan (team, business, enterprise).require_feature(feature_name): Requires a valid license that explicitly includes the feature, or is on the “enterprise” plan. Per-feature access control allows granular commercialization.
No User-Level Licensing
This module does not implement per-seat or per-user licensing (seat counting is not performed). The seats field in the payload is informational; it’s the operator’s responsibility to enforce user limits at the organization or reverse-proxy level.
Dependencies and Integration
Internal Dependencies
fastapi: Used forDepends,HTTPException, and theRequesttype hintpydantic: Used forBaseModelto defineLicensePayloadandLicenseInfocryptography(conditional): Only imported if Ed25519 verification is attempted; if unavailable or key is invalid, falls back to HMACpython-dotenv(optional): Attempts to load.envfiles; gracefully skipped if not installeddatetime: For expiration date parsing and comparison
What Imports This Module
Based on the import graph:
__init__(package init): Re-exports key functions and classes (require_license,require_feature,get_license_info) so they’re available asfrom pocketpaw.ee.cloud import require_licenserouter: A FastAPI router module that usesrequire_license()andrequire_feature()to protect enterprise endpoints
How It Integrates
# In router.py (example usage)from fastapi import APIRouterfrom .license import require_license, require_feature
router = APIRouter(prefix="/api/enterprise")
@router.get("/analytics", dependencies=[Depends(require_license)])async def get_analytics(): return {...}
@router.post("/sso/config", dependencies=[Depends(require_feature("sso"))])async def set_sso_config(config: SSOConfig): return {...}The router imports from license to decorate endpoints, ensuring that only licensed deployments can call them.
Design Decisions
1. Dual-Key Strategy (Ed25519 + HMAC)
Rather than requiring all deployments to manage a public key, the code supports two modes:
- Cloud/SaaS: Customers get a signed license key; the public key is embedded
- Self-hosted: Customers get a secret; they compute an HMAC to verify
This lowers friction for self-hosted deployments while maintaining strong cryptographic guarantees for cloud.
2. Caching the License
The license is loaded once and cached. This avoids repeated environment variable reads and JSON parsing on every request. The cache is never invalidated (licenses are static at runtime), and there’s no background refresh logic, which keeps the code simple but requires a restart to pick up license changes.
3. Fail-Safe Defaults
- Invalid dates → expired
- Missing public key + missing secret → all signatures fail
- Parsing errors → logged and cached as None
These prevent accidental security leaks if configuration is partial.
4. Separation of Validation and Authorization
validate_license_key()is pure: it parses and validates structure/signaturerequire_license()is async and raises HTTP exceptions: it enforces policy
This separation allows unit testing of validation logic independently of FastAPI’s request context.
5. Per-Feature Gating via Dependency Factory
require_feature(feature) returns a closure-based dependency. This allows:
@app.get("/sso", dependencies=[Depends(require_feature("sso"))])@app.get("/analytics", dependencies=[Depends(require_feature("analytics"))])Without the factory pattern, you’d need to hardcode the feature name inside each endpoint. The factory decouples feature names from endpoint definitions.
6. License Info Endpoint (Non-Throwing)
get_license_info() is designed to be called from public, unauthenticated endpoints (like a health check or settings page). It never raises, always returns a LicenseInfo object, and includes error messages for debugging. This lets operators diagnose licensing issues via a simple GET request.
7. Global State (Cached License)
The module uses module-level variables _cached_license and _license_error. This is stateful but acceptable because:
- Licenses don’t change at runtime (no race conditions)
- All threads/workers share the same environment variable
- The cache is read-heavy (every request) and write-once (startup), favoring simplicity over locking
In a future refactor, this could be moved to a singleton service class if the app grows more complex state management.