package main import ( "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" ) const ( entitlementStatusActive = "active" entitlementStatusTrial = "trial" entitlementStatusGrace = "grace" entitlementStatusRevoked = "revoked" entitlementStatusExpired = "expired" entitlementScopeUser = "user" entitlementScopeTenant = "tenant" entitlementScopePlatform = "platform" ) var entitlementActiveStatuses = map[string]struct{}{ entitlementStatusActive: {}, entitlementStatusTrial: {}, entitlementStatusGrace: {}, } func (a *app) entitlementsAdminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "admin") { writeError(w, http.StatusForbidden, "insufficient role for entitlement management") return } next.ServeHTTP(w, r) }) } func (a *app) listEntitlements(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } filter := Doc{} if tenantID, enforceTenant := entitlementTenantScopeFromRequest(r); enforceTenant { filter["tenantId"] = tenantID } if userID := strings.TrimSpace(r.URL.Query().Get("userId")); userID != "" { filter["userId"] = userID } if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" { filter["key"] = key } if status := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); status != "" { filter["status"] = status } docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list entitlements") return } writeJSON(w, http.StatusOK, docs) } func (a *app) createEntitlement(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareEntitlementPayload(Doc(payload), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc = applyEntitlementScopeDefaults(doc, entitlementScopeFromClaims(r)) doc, err = a.create("entitlements", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create entitlement") return } writeJSON(w, http.StatusCreated, doc) } func (a *app) getEntitlement(w http.ResponseWriter, r *http.Request) { a.getCollection("entitlements", w, r) } func (a *app) patchEntitlement(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { writeError(w, http.StatusBadRequest, "entitlement id is required") return } var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } patch, err := prepareEntitlementPayload(Doc(payload), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if tenantID, enforceTenant := entitlementTenantScopeFromRequest(r); enforceTenant { patch["tenantId"] = tenantID patch["scope"] = entitlementScopeTenant } claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims != nil { patch["lastUpdatedByUserId"] = strings.TrimSpace(claims.Sub) } doc, err := a.patch("entitlements", id, patch) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update entitlement") return } writeJSON(w, http.StatusOK, doc) } func (a *app) deleteEntitlement(w http.ResponseWriter, r *http.Request) { a.deleteCollection("entitlements", w, r) } func (a *app) revokeEntitlement(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { writeError(w, http.StatusBadRequest, "entitlement id is required") return } doc, err := a.patch("entitlements", id, Doc{ "status": entitlementStatusRevoked, "revokedAt": time.Now().UnixMilli(), "reason": "manual_revoke", }) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to revoke entitlement") return } writeJSON(w, http.StatusOK, doc) } func (a *app) checkMyEntitlement(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) subject, ok := a.learnerSubjectFromContext(r) if claims == nil || !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } key := strings.TrimSpace(chi.URLParam(r, "key")) if key == "" { writeError(w, http.StatusBadRequest, "entitlement key is required") return } tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) allowed, err := a.hasActiveEntitlementScoped(subject, key, time.Now().UTC(), tenantID, tenantID != "") if err != nil { writeError(w, http.StatusInternalServerError, "failed to check entitlement") return } result := Doc{ "key": key, "allowed": allowed, "scope": entitlementScopeFromClaims(r), "tenantId": tenantID, } if !allowed { result["reason"] = "missing_entitlement" result["message"] = "Your entitlement is missing or inactive for this resource." } writeJSON(w, http.StatusOK, result) } func (a *app) activateMyTrialEntitlement(w http.ResponseWriter, r *http.Request) { a.activateMyPromotionalEntitlement(w, r, entitlementStatusTrial, "trial") } func (a *app) activateMyFreeEntitlement(w http.ResponseWriter, r *http.Request) { a.activateMyPromotionalEntitlement(w, r, entitlementStatusActive, "free_account") } func (a *app) activateMyPromotionalEntitlement(w http.ResponseWriter, r *http.Request, status, source string) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) subject, ok := a.learnerSubjectFromContext(r) if claims == nil || !ok { writeError(w, http.StatusUnauthorized, "missing learner subject") return } key := strings.TrimSpace(chi.URLParam(r, "key")) if key == "" { writeError(w, http.StatusBadRequest, "entitlement key is required") return } days := a.cfg.DefaultTrialDays if source == "free_account" { days = a.cfg.DefaultFreeDays } if days <= 0 { days = 1 } now := time.Now().UTC() scope := entitlementScopeFromClaims(r) tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) filter := Doc{"userId": subject, "key": key} if scope == entitlementScopeTenant && tenantID != "" { filter["tenantId"] = tenantID } existing, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to evaluate existing entitlement") return } if len(existing) > 0 && isEntitlementActive(existing[0], now) { writeJSON(w, http.StatusOK, existing[0]) return } doc, err := prepareEntitlementPayload(Doc{ "userId": subject, "key": key, "status": status, "source": source, "startsAt": now.UnixMilli(), "endsAt": now.Add(time.Duration(days) * 24 * time.Hour).UnixMilli(), "initiatedByUserId": subject, "reason": source + "_activation", }, false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc = applyEntitlementScopeDefaults(doc, scope, tenantID) if source == "free_account" && len(existing) > 0 { previousStatus, _ := asString(existing[0]["status"]) if strings.EqualFold(previousStatus, entitlementStatusTrial) { doc["convertedFromTrialAt"] = now.UnixMilli() doc["convertedFromEntitlementId"] = stringValue(existing[0]["id"]) } } created, err := a.create("entitlements", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to activate entitlement") return } writeJSON(w, http.StatusCreated, created) } func prepareEntitlementPayload(payload Doc, partial bool) (Doc, error) { if payload == nil { payload = Doc{} } userID, hasUserID, err := getOptionalString(payload, "userId") if err != nil { return nil, errors.New("userId must be a string") } if !partial && (!hasUserID || userID == "") { return nil, errors.New("userId is required") } if hasUserID { payload["userId"] = userID } key, hasKey, err := getOptionalString(payload, "key") if err != nil { return nil, errors.New("key must be a string") } if !partial && (!hasKey || key == "") { return nil, errors.New("key is required") } if hasKey { payload["key"] = key } status, hasStatus, err := getOptionalString(payload, "status") if err != nil { return nil, errors.New("status must be a string") } if !partial && !hasStatus { status = entitlementStatusActive hasStatus = true } if hasStatus { normalizedStatus := strings.ToLower(strings.TrimSpace(status)) switch normalizedStatus { case entitlementStatusActive, entitlementStatusTrial, entitlementStatusGrace, entitlementStatusRevoked, entitlementStatusExpired: payload["status"] = normalizedStatus default: return nil, errors.New("status must be one of: active, trial, grace, revoked, expired") } } source, hasSource, err := getOptionalString(payload, "source") if err != nil { return nil, errors.New("source must be a string") } if !partial && !hasSource { source = "manual_override" hasSource = true } if hasSource { payload["source"] = strings.ToLower(strings.TrimSpace(source)) } scope, hasScope, err := getOptionalString(payload, "scope") if err != nil { return nil, errors.New("scope must be a string") } if hasScope { normalizedScope := strings.ToLower(strings.TrimSpace(scope)) switch normalizedScope { case entitlementScopeUser, entitlementScopeTenant, entitlementScopePlatform: payload["scope"] = normalizedScope default: return nil, errors.New("scope must be one of: user, tenant, platform") } } tenantID, hasTenantID, err := getOptionalString(payload, "tenantId") if err != nil { return nil, errors.New("tenantId must be a string") } if hasTenantID { payload["tenantId"] = strings.TrimSpace(tenantID) } platformAccountID, hasPlatformAccountID, err := getOptionalString(payload, "platformAccountId") if err != nil { return nil, errors.New("platformAccountId must be a string") } if hasPlatformAccountID { payload["platformAccountId"] = strings.TrimSpace(platformAccountID) } startsAt, hasStartsAt, err := parseOptionalTime(payload["startsAt"]) if err != nil { return nil, errors.New("startsAt must be a unix milliseconds number or RFC3339 string") } if hasStartsAt { payload["startsAt"] = startsAt.UnixMilli() } endsAt, hasEndsAt, err := parseOptionalTime(payload["endsAt"]) if err != nil { return nil, errors.New("endsAt must be a unix milliseconds number or RFC3339 string") } if hasEndsAt { payload["endsAt"] = endsAt.UnixMilli() } if hasStartsAt && hasEndsAt && endsAt.Before(startsAt) { return nil, errors.New("endsAt must be greater than or equal to startsAt") } revokedAt, hasRevokedAt, err := parseOptionalTime(payload["revokedAt"]) if err != nil { return nil, errors.New("revokedAt must be a unix milliseconds number or RFC3339 string") } if hasRevokedAt { payload["revokedAt"] = revokedAt.UnixMilli() } if metadata, exists := payload["metadata"]; exists && metadata != nil { value, ok := asMap(metadata) if !ok { return nil, errors.New("metadata must be an object") } payload["metadata"] = Doc(value) } if statusRaw, has := payload["status"]; has { statusValue, _ := statusRaw.(string) if statusValue == entitlementStatusRevoked && !hasRevokedAt { payload["revokedAt"] = time.Now().UnixMilli() } } scopeValue, _ := asString(payload["scope"]) switch strings.TrimSpace(scopeValue) { case entitlementScopeTenant: if strings.TrimSpace(anyToString(payload["tenantId"])) == "" { return nil, errors.New("tenantId is required when scope is tenant") } case entitlementScopePlatform: if strings.TrimSpace(anyToString(payload["platformAccountId"])) == "" { payload["platformAccountId"] = "platform" } } return payload, nil } func isEntitlementActive(doc Doc, now time.Time) bool { status, _ := asString(doc["status"]) if _, ok := entitlementActiveStatuses[strings.ToLower(strings.TrimSpace(status))]; !ok { return false } if startsAt, ok := toTime(doc["startsAt"]); ok && now.Before(startsAt.UTC()) { return false } if endsAt, ok := toTime(doc["endsAt"]); ok && now.After(endsAt.UTC()) { return false } if revokedAt, ok := toTime(doc["revokedAt"]); ok && !revokedAt.IsZero() { return false } return true } func (a *app) hasActiveEntitlement(userID, key string, now time.Time) (bool, error) { return a.hasActiveEntitlementScoped(userID, key, now, "", false) } func (a *app) hasActiveEntitlementScoped(userID, key string, now time.Time, tenantID string, enforceTenant bool) (bool, error) { if a.db == nil { return false, errors.New("database not configured") } if strings.TrimSpace(userID) == "" || strings.TrimSpace(key) == "" { return false, nil } filter := Doc{"userId": strings.TrimSpace(userID), "key": strings.TrimSpace(key)} if enforceTenant { filter["tenantId"] = strings.TrimSpace(tenantID) } docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}}) if err != nil { return false, err } now = now.UTC() for _, doc := range docs { if isEntitlementActive(doc, now) { return true, nil } } return false, nil } func (a *app) hasRequiredEntitlement(userID, requiredKey string, enrollment Doc) (bool, error) { required := strings.TrimSpace(requiredKey) if required == "" { return true, nil } return a.hasActiveEntitlement(userID, required, time.Now().UTC()) } func (a *app) learnerEntitlementDecision(r *http.Request, userID, requiredKey string) (bool, string, string, error) { required := strings.TrimSpace(requiredKey) if required == "" { return true, "", "", nil } tenantID, enforceTenant := entitlementTenantScopeFromRequest(r) allowed, err := a.hasActiveEntitlementScoped(userID, required, time.Now().UTC(), tenantID, enforceTenant) if err != nil { return false, "entitlement_check_failed", "Failed to verify entitlement for this resource.", err } if allowed { return true, "", "", nil } return false, "missing_entitlement", "Your entitlement is missing or inactive for this resource.", nil } func entitlementTenantScopeFromRequest(r *http.Request) (string, bool) { if r == nil { return "", false } tenantCtx, _ := r.Context().Value(tenantContextKey).(tenantContext) if tenantCtx.BypassScoping && !tenantCtx.RequestedByOps { return "", false } tenantID := strings.TrimSpace(tenantCtx.ID) if tenantID == "" { return "", false } return tenantID, true } func entitlementScopeFromClaims(r *http.Request) string { if r == nil { return entitlementScopeUser } claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { return entitlementScopeUser } tenantID := strings.TrimSpace(resolveClaimsTenantID(claims)) if tenantID != "" { return entitlementScopeTenant } return entitlementScopeUser } func applyEntitlementScopeDefaults(doc Doc, scope string, tenantID ...string) Doc { if doc == nil { doc = Doc{} } selectedScope := strings.TrimSpace(strings.ToLower(scope)) if selectedScope == "" { selectedScope = entitlementScopeUser } doc["scope"] = selectedScope tenant := "" if len(tenantID) > 0 { tenant = strings.TrimSpace(tenantID[0]) } if selectedScope == entitlementScopeTenant { if tenant == "" { tenant = strings.TrimSpace(anyToString(doc["tenantId"])) } doc["tenantId"] = tenant } if selectedScope == entitlementScopePlatform { doc["platformAccountId"] = "platform" } return doc }