using System.Security.Cryptography; using System.Text; using System.Text.Json; using EntitlementBLL.Common; using EntitlementBLL.Exceptions; using EntitlementBLL.Interfaces; using EntitlementDAL.DTOs; using EntitlementDAL.Enums; using EntitlementDAL.Interfaces; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; namespace EntitlementBLL.Implementations; /// /// The 9-layer feature resolution engine. The layer order inside IsFeatureEnabledAsync is /// HARD-CODED, NO EXCEPTIONS, NO BYPASS — every layer runs in the exact sequence below and no /// caller (including GOODBOOKS_ADMIN) can skip an earlier layer's DENY. /// /// Design note on Layer 5's beta-programme correlation: the schema (MENTITLEMENTFEATUREFLAG) has /// no explicit "which beta programme" column, so this implementation uses FLAGNAME as the beta /// programme identifier when ROLLOUTTYPE=BetaClients (i.e. a flag named "NewInventoryUI" gates on /// enrollment in the beta programme of the same name). This is a documented interpretation, not /// specified further by the source algorithm. /// /// Design note on Layer 8's quota check: the schema has no dedicated usage-tracking table, so /// this implementation treats Quota-type features as drawing from the subscription's Web license /// seat pool (LicenseType=0) — SEATS vs. active MENTITLEMENTLICENSEASSIGNMENT count. This is the /// only seat-like usage signal available in the Phase-1 schema. /// public class EntitlementService : IEntitlementService { private readonly IFeatureDAL _FeatureDAL; private readonly IFeatureFlagDAL _FeatureFlagDAL; private readonly IEntitlementGrantDAL _GrantDAL; private readonly IBetaClientDAL _BetaClientDAL; private readonly ILicenseDAL _LicenseDAL; private readonly ISubscriptionDAL _SubscriptionDAL; private readonly IEntitlementLoginFactory _LoginFactory; public EntitlementService( IFeatureDAL featureDAL, IFeatureFlagDAL featureFlagDAL, IEntitlementGrantDAL grantDAL, IBetaClientDAL betaClientDAL, ILicenseDAL licenseDAL, ISubscriptionDAL subscriptionDAL, IEntitlementLoginFactory loginFactory) { _FeatureDAL = featureDAL; _FeatureFlagDAL = featureFlagDAL; _GrantDAL = grantDAL; _BetaClientDAL = betaClientDAL; _LicenseDAL = licenseDAL; _SubscriptionDAL = subscriptionDAL; _LoginFactory = loginFactory; } public async Task IsFeatureEnabledAsync(int clientId, int userId, string featureCode, CancellationToken ct) { var login = _LoginFactory.Create(clientId, userId); GB5Trace.Step("resolve-feature", new { clientId, userId, featureCode }); var flag = await _FeatureFlagDAL.GetByFeatureCodeAsync(featureCode, login, ct).ConfigureAwait(false); // ── Layer 1: kill switch — absolute, cannot be bypassed by any role, ever ────────── if (flag is not null && flag.FlagStatus == (byte)FlagStatusEnum.KillSwitch) return false; // ── Layer 2: not licensed ─────────────────────────────────────────────────────────── var grant = await _GrantDAL.GetByClientAndFeatureCodeAsync(clientId, featureCode, login, ct).ConfigureAwait(false); if (grant is not null && grant.IsEnabled == 0) return false; // No flag row at all → feature has no rollout gating; fall through to the licence grant // (or default-allow if there's no grant row either, per Layer 9). if (flag is null) return grant is null || (grant.IsEnabled != 0 && WithinValidity(grant)); // ── Layer 3: globally off ─────────────────────────────────────────────────────────── if (flag.FlagStatus == (byte)FlagStatusEnum.Off) return false; // ── Layer 4: per-user target override wins ────────────────────────────────────────── var userTarget = await _FeatureFlagDAL.GetUserTargetAsync(flag.FeatureFlagId, userId, login, ct).ConfigureAwait(false); if (userTarget is not null) return userTarget.IsEnabled != 0; // ── Layer 5: per-client target override, or beta-programme membership ────────────── var clientTarget = await _FeatureFlagDAL.GetClientTargetAsync(flag.FeatureFlagId, clientId, login, ct).ConfigureAwait(false); if (clientTarget is not null) return clientTarget.IsEnabled != 0; if (flag.RolloutType == (byte)RolloutTypeEnum.BetaClients) { var inProgram = await _BetaClientDAL.IsClientInProgramAsync(clientId, flag.FlagName, DateTime.UtcNow, login, ct).ConfigureAwait(false); if (inProgram) return true; } // ── Layer 6: percentage rollout — deterministic hash of (userId + featureCode) ────── if (flag.RolloutType == (byte)RolloutTypeEnum.Percentage) { var pct = PercentageBucket(userId, featureCode); return pct < flag.RolloutPercent; } // ── Layer 7: global on, gated by the entitlement's validity window ───────────────── // A definitive ALLOW/DENY here only fires when the flag is specifically Global-On; any // other combination (e.g. On + SelectedClients/SelectedUsers with no matching target // resolved above) falls through to Layer 8/9 rather than returning here. if (flag.FlagStatus == (byte)FlagStatusEnum.On && flag.RolloutType == (byte)RolloutTypeEnum.Global && grant is not null && !WithinValidity(grant)) { return false; } // ── Layer 8: quota-type features draw against the Web licence seat pool ──────────── // Runs whenever resolution reaches this point without an earlier layer returning — // unconditional on rollout type, per the algorithm's literal ordering. var feature = await _FeatureDAL.GetByCodeAsync(featureCode, login, ct).ConfigureAwait(false); if (feature is not null && feature.FeatureType == (byte)FeatureTypeEnum.Quota) { var sub = await _SubscriptionDAL.GetByClientAsync(clientId, login, ct).ConfigureAwait(false); if (sub is not null) { var license = await _LicenseDAL.GetBySubscriptionAndTypeAsync(sub.SubscriptionId, (byte)LicenseTypeEnum.Web, login, ct).ConfigureAwait(false); if (license is not null) { var used = await _LicenseDAL.GetActiveAssignmentCountAsync(license.LicenseId, login, ct).ConfigureAwait(false); if (used >= license.Seats) return false; } } } // ── Layer 9: default allow ─────────────────────────────────────────────────────────── return true; } private static bool WithinValidity(EntitlementDTO grant) { var now = DateTime.UtcNow; return grant.ValidFrom <= now && now <= grant.ValidUntil; } /// Layer 6's exact hashing mechanism: SHA256(UTF8Bytes(userId + featureCode)), /// bucket = first 4 bytes as uint32 % 100. Deterministic for a given (userId, featureCode) /// pair — same inputs always produce the same bucket. internal static uint PercentageBucket(int userId, string featureCode) { using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(userId.ToString() + featureCode)); return BitConverter.ToUInt32(hash, 0) % 100; } public async Task EnsureQuotaAsync(int clientId, string featureCode, long delta, CancellationToken ct) { var login = _LoginFactory.Create(clientId); GB5Trace.Step("ensure-quota", new { clientId, featureCode, delta }); var grant = await _GrantDAL.GetByClientAndFeatureCodeAsync(clientId, featureCode, login, ct).ConfigureAwait(false); if (grant?.FeatureValue is null) return; // no quota configured — unlimited long quota; try { using var doc = JsonDocument.Parse(grant.FeatureValue); quota = doc.RootElement.TryGetProperty("quota", out var q) ? q.GetInt64() : long.MaxValue; } catch (JsonException) { quota = long.MaxValue; } if (delta > quota) { GB5Trace.MarkFailed("quota-exceeded", null); throw new EntitlementQuotaExceededException(featureCode, currentUsage: 0, quota: quota, delta: delta); } } public async Task GetRemainingSeatsAsync(int clientId, LicenseTypeEnum licenseType, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var sub = await _SubscriptionDAL.GetByClientAsync(clientId, login, ct).ConfigureAwait(false); if (sub is null) return 0; var license = await _LicenseDAL.GetBySubscriptionAndTypeAsync(sub.SubscriptionId, (byte)licenseType, login, ct).ConfigureAwait(false); if (license is null) return 0; var used = await _LicenseDAL.GetActiveAssignmentCountAsync(license.LicenseId, login, ct).ConfigureAwait(false); return Math.Max(0, license.Seats - used); } public async Task GetResolvedBundleAsync(int clientId, CancellationToken ct) { var login = _LoginFactory.Create(clientId); var sub = await _SubscriptionDAL.GetByClientAsync(clientId, login, ct).ConfigureAwait(false) ?? throw new EntitlementNotFoundException($"No subscription found for ClientId {clientId}."); var grants = await _GrantDAL.GetBySubscriptionAsync(sub.SubscriptionId, login, ct).ConfigureAwait(false); var licenses = await _LicenseDAL.GetBySubscriptionAsync(sub.SubscriptionId, login, ct).ConfigureAwait(false); var programs = await _BetaClientDAL.GetOpenProgramsAsync(DateTime.UtcNow, login, ct).ConfigureAwait(false); var bundle = new EntitlementBundleDto { TenantId = clientId, Subscriptions = new List { sub.SubscriptionId }, ValidUntil = sub.LicenseValidTill, IssuedAt = DateTime.UtcNow, BetaPrograms = programs.ToList() }; foreach (var g in grants) { var feature = await _FeatureDAL.GetByIdAsync(g.FeatureId, login, ct).ConfigureAwait(false); var flag = await _FeatureFlagDAL.GetByFeatureIdAsync(g.FeatureId, login, ct).ConfigureAwait(false); var flagState = await ResolveBundleFlagStateAsync(clientId, g, flag, login, ct).ConfigureAwait(false); bundle.Entitlements.Add(new BundleEntitlementItemDto { Feature = feature?.FeatureCode ?? g.FeatureId.ToString(), Enabled = g.IsEnabled != 0, Quota = g.FeatureValue, FeatureGroupId = feature?.FeatureGroupId ?? -1, FlagState = flagState }); } foreach (var l in licenses) { bundle.Licenses[((LicenseTypeEnum)l.LicenseType).ToString()] = new BundleLicenseItemDto { LicenseType = ((LicenseTypeEnum)l.LicenseType).ToString(), Seats = l.Seats, ExpiryDate = l.ExpiryDate }; } return bundle; } /// /// Coarse, client-level flag-state classification for bundle payloads. Mirrors — does not /// duplicate ad hoc — the same FlagStatusEnum/RolloutTypeEnum values and the same /// _BetaClientDAL.IsClientInProgramAsync call that Layers 1/2/3/5 of /// IsFeatureEnabledAsync above already use, collapsed to the smaller BundleFlagStateEnum /// vocabulary a bundle consumer needs. Bundles are issued per-client with no specific user in /// scope, so the per-user layers (4 user-target override, 6 percentage rollout) cannot be /// resolved here — those remain live-checked per request via IsFeatureEnabledAsync, never /// baked into the signed snapshot. /// private async Task ResolveBundleFlagStateAsync( int clientId, EntitlementDTO grant, FeatureFlagDTO? flag, LoginDTO login, CancellationToken ct) { // Mirrors Layer 2: not licensed at all. if (grant.IsEnabled == 0) return BundleFlagStateEnum.Hidden; if (flag is null) return BundleFlagStateEnum.Normal; // Mirrors Layer 1: kill switch — client-facing wording is "Undergoing maintenance", // never "Kill Switch" (per the client-portal design decision). if (flag.FlagStatus == (byte)FlagStatusEnum.KillSwitch) return BundleFlagStateEnum.Maintenance; // Mirrors Layer 3: globally off. if (flag.FlagStatus == (byte)FlagStatusEnum.Off) return BundleFlagStateEnum.Hidden; // Mirrors Layer 5's beta-programme correlation (same FlagName-as-programme-name // convention documented on the class above). if (flag.RolloutType == (byte)RolloutTypeEnum.BetaClients) { var inProgram = await _BetaClientDAL.IsClientInProgramAsync(clientId, flag.FlagName, DateTime.UtcNow, login, ct).ConfigureAwait(false); return inProgram ? BundleFlagStateEnum.BetaPreview : BundleFlagStateEnum.Hidden; } return BundleFlagStateEnum.Normal; } }