Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -3101,7 +3101,7 @@ enum CustomFeature: string implements FeatureEnumContract {
}
```

As long as your enum implementation satisfies the `FeatureEnumContract&BackedEnum` type, you will
As long as your enum implementation satisfies the `FeatureEnumContract` type, you will
be able to pass the feature to the main API methods like `$user->hasAccess()`;

Additionally, these features and the required metadata will need to be populated in the DB.
Expand Down
8 changes: 4 additions & 4 deletions config/cashier.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,16 @@

'entitlements' => [
// Enable Chargebee Entitlements for Cashier
'enabled' => true,
'enabled' => env('CASHIER_ENTITLEMENTS_ENABLED', false),

// The class that will be used to check if the user has access to the feature.
// If this is not provided, the default implementation expects fallback_access
// to be provided.
'access_verifier' => \Chargebee\Cashier\Support\DefaultEntitlementAccessVerifier::class,
'access_verifier' => env('CASHIER_ENTITLEMENTS_ACCESS_VERIFIER'),

// Map of FeatureID => boolean which is used as a fallback to determine if
// array of FeatureID => boolean which is used as a fallback to determine if
// the user has access to the feature. This is used only if a access_check
// class is not provided.
'feature_defaults' => [],
'feature_defaults' => env('CASHIER_ENTITLEMENTS_FEATURE_DEFAULTS'),
Comment thread
cb-alish marked this conversation as resolved.
],
];
3 changes: 1 addition & 2 deletions src/CashierServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Chargebee\Cashier;

use BackedEnum;
use Chargebee\Cashier\Console\FeatureEnumCommand;
use Chargebee\Cashier\Console\WebhookCommand;
use Chargebee\Cashier\Contracts\EntitlementAccessVerifier;
Expand Down Expand Up @@ -153,7 +152,7 @@ protected function enableEntitlements(): void

// Initialise the route macro, which binds the middleware to the route
// and reads the required features from the route action.
\Illuminate\Routing\Route::macro('requiresEntitlement', function (FeatureEnumContract&BackedEnum ...$features) {
\Illuminate\Routing\Route::macro('requiresEntitlement', function (FeatureEnumContract ...$features) {
/** @var \Illuminate\Routing\Route $this */
$this->middleware(UserEntitlementCheck::class);

Expand Down
52 changes: 41 additions & 11 deletions src/Concerns/HasEntitlements.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace Chargebee\Cashier\Concerns;

use BackedEnum;
use Chargebee\Cashier\Contracts\EntitlementAccessVerifier;
use Chargebee\Cashier\Contracts\FeatureEnumContract;
use Chargebee\Cashier\Entitlement;
use Chargebee\Cashier\EntitlementErrorCode;
use Chargebee\Cashier\Feature;
use Chargebee\Cashier\Subscription;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -82,32 +83,61 @@ public function ensureEntitlements(): void
$cachedEntitlements = $cacheStore->get($cacheKey);
if ($cachedEntitlements) {
Log::debug('Got entitlements from cache: ', ['cachedEntitlements' => $cachedEntitlements]);
// Convert the cached entitlements to an array of Entitlement objects
$this->entitlements = collect($cachedEntitlements)->map(fn ($entitlement) => Entitlement::fromArray($entitlement));
} else {
$entitlements = $this->getEntitlements();
Log::debug('Got entitlements from API: ', ['entitlements' => $entitlements]);
$cacheExpirySeconds = config('session.lifetime', 120) * 60;
$cacheStore->put($cacheKey, $entitlements, $cacheExpirySeconds);
$cacheStore->put($cacheKey, $entitlements->toArray(), $cacheExpirySeconds);
}
}

/**
* Check if the given features are missing in the database
*
* @param Collection<FeatureEnumContract> $features
* @param Collection<Feature> $featureModels
* @return Collection<FeatureEnumContract>|null
*/
protected function checkMissingFeatures(Collection $features, Collection $featureModels): ?Collection
{
$missingFeatureIds = $features->reject(function ($enum) use ($featureModels) {
return $featureModels->contains(function ($model) use ($enum) {
return $enum->id() === $model->chargebee_id;
});
});
if ($missingFeatureIds->count() > 0) {
return $missingFeatureIds;
}

return null;
}

/**
* Check if the user has the given entitlement
*
* @param FeatureEnumContract&BackedEnum ...$features
* @param FeatureEnumContract|array<FeatureEnumContract> $features
* @param ?Request $request
* @return bool
*/
public function hasAccess(FeatureEnumContract&BackedEnum ...$features): bool
public function hasAccess($features, ?Request $request = null): bool
{
$featureModels = Feature::whereIn('chargebee_id', $features)->get();
$feats = collect($features);
if ($featureModels->count() != $feats->count()) {
Log::warning(<<<'EOF'
Some features were not found in the database. Please run `php artisan cashier:generate-feature-enum` to sync.
EOF, ['missingFeatures' => $feats->diff($featureModels)->implode(', ')]);
$featureModels = Feature::whereIn('chargebee_id', $features)->get();
Comment thread
cb-alish marked this conversation as resolved.
$request = $request ?? request();
$entitlementAccessVerifier = app(EntitlementAccessVerifier::class);

$missingFeatures = $this->checkMissingFeatures($feats, $featureModels);
if ($missingFeatures && $missingFeatures->count() > 0) {
Log::error(<<<'EOF'
Feature(s) missing in database. Run `php artisan cashier:generate-feature-enum` to sync.
EOF, ['missingFeatures' => $missingFeatures]);

$entitlementAccessVerifier::handleError($request, EntitlementErrorCode::MISSING_FEATURE_IN_DB, $missingFeatures);
Comment thread
cursor[bot] marked this conversation as resolved.

return false;
}

return app(EntitlementAccessVerifier::class)::hasAccessToFeatures($this, $featureModels);
return $entitlementAccessVerifier::hasAccessToFeatures($request, $featureModels);
}
}
6 changes: 6 additions & 0 deletions src/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ class Constants
{
public const REQUIRED_FEATURES_KEY = 'chargebee.required_features';
}

enum EntitlementErrorCode
{
case MISSING_FEATURE_IN_DB;
case ACCESS_DENIED;
}
21 changes: 16 additions & 5 deletions src/Contracts/EntitlementAccessVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

namespace Chargebee\Cashier\Contracts;

use Chargebee\Cashier\Concerns\HasEntitlements;
use Chargebee\Cashier\EntitlementErrorCode;
use Chargebee\Cashier\Feature;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;

interface EntitlementAccessVerifier
{
/**
* For the given user, decide if feature is accessible to them. The entitlements
* for the user are accessible via $user->getEntitlements(). The implementation in the
* for the user are accessible via $request->user()->getEntitlements(). The implementation in the
* app will need to consider variour factors like feature type, value, levels, etc.
*
* If multiple features are defined on the route, those are passed as an array to this method.
Expand All @@ -20,9 +20,20 @@ interface EntitlementAccessVerifier
* If you also track usage of these features in your app, apply the required logic to verify
* if the usage is within the entitled limits.
*
* @param Authenticatable&HasEntitlements $user The user to check access for
* @param Request $request
* @param Collection<Feature> $features
* @return bool
*/
public static function hasAccessToFeatures($user, Collection $features): bool;
public static function hasAccessToFeatures(Request $request, Collection $features): bool;

/**
* When hasAccessToFeatures returns false, this method will be called to handle the access denied case.
* You can throw an exception, return a response, or redirect to a different page.
*
* @param Request $request
* @param EntitlementErrorCode $error
* @param mixed $data
* @return void
*/
public static function handleError(Request $request, EntitlementErrorCode $error, mixed $data = null): void;
Comment thread
cb-alish marked this conversation as resolved.
}
4 changes: 3 additions & 1 deletion src/Contracts/FeatureEnumContract.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Chargebee\Cashier\Contracts;

interface FeatureEnumContract
use BackedEnum;

interface FeatureEnumContract extends BackedEnum
{
/**
* Returns the chargebee id of the feature.
Expand Down
3 changes: 1 addition & 2 deletions src/Entitlement.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Chargebee\Cashier;

use BackedEnum;
use Chargebee\Cashier\Contracts\FeatureEnumContract;
use Chargebee\Resources\SubscriptionEntitlement\SubscriptionEntitlement as ChargebeeSubscriptionEntitlement;
use Illuminate\Contracts\Support\Arrayable;
Expand Down Expand Up @@ -48,7 +47,7 @@ public function feature(): Feature
*
* @return bool
*/
public function providesFeature(FeatureEnumContract&BackedEnum $feature): bool
public function providesFeature(FeatureEnumContract $feature): bool
{
return $this->entitlement->feature_id === $feature->id();
}
Expand Down
17 changes: 10 additions & 7 deletions src/Http/Middleware/UserEntitlementCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

namespace Chargebee\Cashier\Http\Middleware;

use BackedEnum;
use Chargebee\Cashier\Concerns\HasEntitlements;
use Chargebee\Cashier\Constants;
use Chargebee\Cashier\Contracts\EntitlementAccessVerifier;
use Chargebee\Cashier\Contracts\FeatureEnumContract;
use Chargebee\Cashier\EntitlementErrorCode;
use Chargebee\Cashier\Support\RequiresEntitlement;
use Closure;
use Illuminate\Contracts\Auth\Authenticatable;
Expand All @@ -30,26 +31,28 @@ public function handle(Request $request, Closure $next)

// 2) Or from route macro (closure routes)
if (! $features) {
/** @var null|array<FeatureEnumContract&BackedEnum> $fromAction */
/** @var null|array<FeatureEnumContract> $fromAction */
$fromAction = $route->getAction(Constants::REQUIRED_FEATURES_KEY) ?? null;
if ($fromAction) {
$features = $fromAction;
}
}
if ($features) {
$hasAccess = $user->hasAccess(...$features);
$request->attributes->set(Constants::REQUIRED_FEATURES_KEY, $features);
$hasAccess = $user->hasAccess($features, $request);

if (! $hasAccess) {
throw new HttpException(403, 'You are not authorized to access this resource.');
}
$entitlementAccessVerifier = app(EntitlementAccessVerifier::class);

$request->attributes->set(Constants::REQUIRED_FEATURES_KEY, $features);
return $entitlementAccessVerifier::handleError($request, EntitlementErrorCode::ACCESS_DENIED);
}
}

return $next($request);
}

/**
* @return array<FeatureEnumContract&BackedEnum>
* @return array<FeatureEnumContract>
*/
private function featuresFromAttributes($route): array
{
Expand Down
8 changes: 4 additions & 4 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -1025,21 +1025,21 @@ public function asChargebeeSubscription(): ChargebeeSubscription
/**
* Get entitlements
*
* @return \Chargebee\Cashier\Entitlement[]
* @return \Illuminate\Support\Collection<Entitlement>
*/
public function getEntitlements(): array
public function getEntitlements(): Collection
{
Log::debug('Getting entitlements for subscription '.$this->chargebee_id);
$chargebee = Cashier::chargebee();
$entitlements = [];
$entitlements = Collection::make();
$options = [];

do {
$response = $chargebee->subscriptionEntitlement()->subscriptionEntitlementsForSubscription($this->chargebee_id, $options);
$entitlementsResponse = collect($response->list)->map(function ($entitlement) {
return new Entitlement($entitlement->subscription_entitlement);
});
array_push($entitlements, ...$entitlementsResponse->toArray());
$entitlements->push(...$entitlementsResponse);
if ($response->next_offset) {
$options['offset'] = $response->next_offset;
}
Expand Down
35 changes: 28 additions & 7 deletions src/Support/DefaultEntitlementAccessVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,40 @@

namespace Chargebee\Cashier\Support;

use Chargebee\Cashier\Concerns\HasEntitlements;
use Chargebee\Cashier\Contracts\EntitlementAccessVerifier;
use Chargebee\Cashier\Entitlement;
use Chargebee\Cashier\EntitlementErrorCode;
use Chargebee\Cashier\Feature;
use Chargebee\Resources\Feature\Enums\Type as FeatureType;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\HttpException;

final class DefaultEntitlementAccessVerifier implements EntitlementAccessVerifier
{
/**
* Return true if the entitlements collectively provide all requested features.
*
* @param Authenticatable&HasEntitlements $user The user to check access for
* @param Request $request
* @param Collection<Feature> $features
* @return bool
*/
public static function hasAccessToFeatures($user, Collection $features): bool
public static function hasAccessToFeatures(Request $request, Collection $features): bool
{
$entitlements = $user->getEntitlements();
/** @var Collection<Entitlement> $entitlements */
$entitlements = $request->user()?->getEntitlements() ?? collect();
$featureDefaults = config('cashier.entitlements.feature_defaults', []);

// Every feature must be provided by at least one entitlement (AND logic)
return $features->every(function ($feature) use ($entitlements, $featureDefaults) {
return $entitlements->contains(function ($entitlement) use ($feature, $featureDefaults) {
// For the default implementation, we can only check SWITCH feature types.
// For the others, check if there is a fallback value configured.
// If the entitlement does not match the feature being checked, bail.
if ($entitlement->feature_id !== $feature->chargebee_id) {
return false;
}

// For the default implementation, we can only check SWITCH feature types.
// For the others, check if there is a fallback value configured in the config file.
$hasAccess = match (FeatureType::tryFromValue(strtolower($entitlement->feature_type))) {
FeatureType::SWITCH => $entitlement->value,
default => $featureDefaults[$feature->chargebee_id] ?? false,
Expand All @@ -43,4 +46,22 @@ public static function hasAccessToFeatures($user, Collection $features): bool
});
});
}

/**
* Throw a 403 error when access is denied.
*
* @param Request $request
* @param EntitlementErrorCode $error
* @param mixed $data
* @return void
*/
public static function handleError(Request $request, EntitlementErrorCode $error, mixed $data = null): void
{
switch ($error) {
case EntitlementErrorCode::MISSING_FEATURE_IN_DB:
throw new HttpException(500, 'Error verifying your access to this resource.');
case EntitlementErrorCode::ACCESS_DENIED:
throw new HttpException(403, 'You are not authorized to access this resource.');
}
}
}
7 changes: 3 additions & 4 deletions src/Support/RequiresEntitlement.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@
namespace Chargebee\Cashier\Support;

use Attribute;
use BackedEnum;
use Chargebee\Cashier\Contracts\FeatureEnumContract;

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class RequiresEntitlement
{
/**
* @var list<FeatureEnumContract&BackedEnum>
* @var list<FeatureEnumContract>
*/
public array $features;

/**
* @param FeatureEnumContract&BackedEnum ...$features
* @param FeatureEnumContract ...$features
*/
public function __construct(FeatureEnumContract&BackedEnum ...$features)
public function __construct(FeatureEnumContract ...$features)
{
$this->features = $features;
}
Expand Down