572 lines
16 KiB
Go
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
|
|
}
|