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;
}
}