572 lines
16 KiB
Go

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
}