Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

entitlements read from wingback #4804

Merged
merged 8 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions alkemio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ licensing:
enabled: ${LICENSING_WINGBACK_ENABLED}:false
key: ${LICENSING_WINGBACK_API_KEY}
endpoint: ${LICENSING_WINGBACK_API_ENDPOINT}
retries: ${LICENSING_WINGBACK_RETRY}:3
timeout: ${LICENSING_WINGBACK_TIMEOUT}:30000

## identity ##

Expand Down
1 change: 1 addition & 0 deletions src/common/enums/logging.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,5 @@ export enum LogContext {
AI_SERVER_EVENT_BUS = 'ai-server-event-bus',
SUBSCRIPTION_PUBLISH = 'subscription-publish',
KRATOS = 'kratos',
WINGBACK = 'wingback',
}
17 changes: 17 additions & 0 deletions src/common/exceptions/internal/base.exception.internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { randomUUID } from 'crypto';
import { LogContext } from '@common/enums';
import { ExceptionDetails } from '../exception.details';

export class BaseExceptionInternal extends Error {
private readonly exceptionName = this.constructor.name;

constructor(
public readonly message: string,
public readonly context: LogContext,
public readonly details?: ExceptionDetails,
public readonly errorId: string = randomUUID()
) {
super(message);
this.name = this.constructor.name;
}
}
2 changes: 2 additions & 0 deletions src/common/exceptions/internal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './retry.exception';
export * from './timeout.exception';
9 changes: 9 additions & 0 deletions src/common/exceptions/internal/retry.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BaseExceptionInternal } from '@common/exceptions/internal/base.exception.internal';
import { ExceptionDetails } from '@common/exceptions/exception.details';
import { LogContext } from '@common/enums';

export class RetryException extends BaseExceptionInternal {
constructor(error: string, context: LogContext, details?: ExceptionDetails) {
super(error, context, details);
}
}
9 changes: 9 additions & 0 deletions src/common/exceptions/internal/timeout.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BaseExceptionInternal } from '@common/exceptions/internal/base.exception.internal';
import { ExceptionDetails } from '@common/exceptions/exception.details';
import { LogContext } from '@common/enums';

export class TimeoutException extends BaseExceptionInternal {
constructor(error: string, context: LogContext, details?: ExceptionDetails) {
super(error, context, details);
}
}
20 changes: 20 additions & 0 deletions src/core/error-handling/unhandled.exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
ArgumentsHost,
} from '@nestjs/common';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { GraphQLError } from 'graphql';
import { ContextTypeWithGraphQL } from '@src/types/context.type';
import { AlkemioErrorStatus } from '@common/enums';

@Catch(Error)
export class UnhandledExceptionFilter implements ExceptionFilter {
Expand Down Expand Up @@ -46,6 +48,24 @@ export class UnhandledExceptionFilter implements ExceptionFilter {
stack:
process.env.NODE_ENV !== 'production' ? exception.stack : undefined,
});
} else if (contextType === 'graphql') {
if (process.env.NODE_ENV === 'production') {
// return a new error with only the message and the id
// that way we are not exposing any internal information
return new GraphQLError(exception.message, {
extensions: {
errorId: secondParam.errorId,
code: AlkemioErrorStatus.UNSPECIFIED,
},
});
}
// if not in PROD, return everything
return new GraphQLError(exception.message, {
extensions: {
...exception,
message: undefined, // do not repeat the message
},
});
hero101 marked this conversation as resolved.
Show resolved Hide resolved
}
// something needs to be returned so the default ExceptionsHandler is not triggered
return exception;
Expand Down
24 changes: 16 additions & 8 deletions src/domain/space/account/account.service.license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { SpaceLicenseService } from '../space/space.service.license';
import { LicensingWingbackSubscriptionService } from '@platform/licensing/wingback-subscription/licensing.wingback.subscription.service';
import { ILicenseEntitlement } from '@domain/common/license-entitlement/license.entitlement.interface';
import { LicensingGrantedEntitlement } from '@platform/licensing/dto/licensing.dto.granted.entitlement';

@Injectable()
export class AccountLicenseService {
Expand Down Expand Up @@ -91,20 +92,27 @@ export class AccountLicenseService {

// Then check the Wingback subscription service for any granted entitlements
if (account.externalSubscriptionID) {
const wingbackGrantedLicenseEntitlements =
await this.licensingWingbackSubscriptionService.getEntitlements(
account.externalSubscriptionID
const wingbackGrantedLicenseEntitlements: LicensingGrantedEntitlement[] =
[];

try {
const result =
await this.licensingWingbackSubscriptionService.getEntitlements(
account.externalSubscriptionID
);
wingbackGrantedLicenseEntitlements.push(...result);
} catch (e: any) {
this.logger.warn?.(
`Skipping Wingback entitlements for account ${account.id} since it returned with an error: ${e}`,
LogContext.ACCOUNT
);
this.logger.verbose?.(
`Invoking external subscription service for account ${account.id}, entitlements ${wingbackGrantedLicenseEntitlements}`,
LogContext.ACCOUNT
);
}

for (const entitlement of license.entitlements) {
const wingbackGrantedEntitlement =
wingbackGrantedLicenseEntitlements.find(
e => e.type === entitlement.type
);
// Note: for now overwrite the credential based entitlements with the Wingback entitlements
if (wingbackGrantedEntitlement) {
entitlement.limit = wingbackGrantedEntitlement.limit;
entitlement.enabled = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AuthorizationService } from '@core/authorization/authorization.service'
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { randomUUID } from 'crypto';
import { LicensingWingbackSubscriptionService } from './licensing.wingback.subscription.service';
import { LicensingGrantedEntitlement } from '@platform/licensing/dto/licensing.dto.granted.entitlement';

@Resolver()
export class LicensingWingbackSubscriptionServiceResolverMutations {
Expand Down Expand Up @@ -38,8 +39,7 @@ export class LicensingWingbackSubscriptionServiceResolverMutations {
const res = await this.licensingWingbackSubscriptionService.createCustomer({
name: `Test User ${randomUUID()}`,
emails: {
main: `main${randomUUID()}@alkem.io`,
secondary: `secondary${randomUUID()}@alkem.io`,
invoice: `main${randomUUID()}@alkem.io`,
},
tax_details: {
vat_id: 'vat_id',
Expand All @@ -53,7 +53,7 @@ export class LicensingWingbackSubscriptionServiceResolverMutations {

// todo: move
@UseGuards(GraphqlGuard)
@Mutation(() => String, {
@Mutation(() => [LicensingGrantedEntitlement], {
description: 'Get wingback customer entitlements.',
})
public async adminWingbackGetCustomerEntitlements(
Expand All @@ -68,10 +68,8 @@ export class LicensingWingbackSubscriptionServiceResolverMutations {
AuthorizationPrivilege.PLATFORM_ADMIN,
`licensing wingback subscription entitlements: ${agentInfo.email}`
);
const result =
await this.licensingWingbackSubscriptionService.getEntitlements(
customerId
);
return JSON.stringify(result, null, 2);
return this.licensingWingbackSubscriptionService.getEntitlements(
customerId
);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { Inject, Injectable, LoggerService } from '@nestjs/common';
import { CreateCustomer } from '../../../services/external/wingback/types/wingback.type.create.customer';
import { WingbackManager } from '@services/external/wingback/wingback.manager';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { LogContext } from '@common/enums';
import { WingbackManager } from '@services/external/wingback/wingback.manager';
import {
WingbackFeature,
WingbackTypedFeature,
} from '@services/external/wingback/types/wingback.type.feature';
import {
isWingbackFeaturePerUnit,
WingbackFeatureDetailPerUnit,
} from '@services/external/wingback/types/entitlement-details/wingback.feature.detail.per.unit';
import {
WingbackFeatureMapping,
WingbackFeatureNames,
} from '@platform/licensing/wingback-subscription/wingback.constants';
import { CreateCustomer } from '@services/external/wingback/types/wingback.type.create.customer';
import { LicensingGrantedEntitlement } from '../dto/licensing.dto.granted.entitlement';

@Injectable()
Expand All @@ -22,17 +34,64 @@ export class LicensingWingbackSubscriptionService {
return this.wingbackManager.createCustomer(data);
}

/**
* Returns the supported by Alkemio entitlements for the customer
* @param customerId
* @throws {Error}
*/
public async getEntitlements(
customerId: string
): Promise<LicensingGrantedEntitlement[]> {
const entitlements: LicensingGrantedEntitlement[] = [];
const wingbackEntitlements =
const wingbackFeatures =
await this.wingbackManager.getEntitlements(customerId);
// Todo: map the wingback entitlements to the entitlements that are understood within Alkemio Licensing

this.logger.verbose?.(
`Wingback entitlements: ${JSON.stringify(wingbackEntitlements)}`,
LogContext.LICENSE
`Wingback returned with ${wingbackFeatures.length} features for customer: '${customerId}'`,
LogContext.WINGBACK
);
return entitlements;

return this.toLicensingGrantedEntitlements(wingbackFeatures);
}

/**
* Maps Wingback features to LicensingGrantedEntitlements.
* Supports only PER-UNIT pricing strategy
* @param features
*/
private toLicensingGrantedEntitlements = (
features: WingbackFeature[]
): LicensingGrantedEntitlement[] => {
this.logger.verbose?.(
'Filtering only "per_unit" pricing strategy features',
LogContext.WINGBACK
);
const supportedFeatures = features.filter(
(
feature
): feature is WingbackTypedFeature<WingbackFeatureDetailPerUnit> =>
isWingbackFeaturePerUnit(feature)
);

const entitlements: (LicensingGrantedEntitlement | undefined)[] =
supportedFeatures.map(({ slug, entitlement_details }) => {
const licenseEntitlementType =
WingbackFeatureMapping[slug as WingbackFeatureNames];
if (!licenseEntitlementType) {
// if the entitlement name is not recognized return undefined
this.logger.warn?.(
`Unsupported mapping between the Wingback feature: "${slug}" and Alkemio`
);
return undefined;
}

return {
type: licenseEntitlementType,
limit: Number(entitlement_details.contracted_unit_count),
};
});

return entitlements.filter(
(entitlement): entitlement is LicensingGrantedEntitlement => !!entitlement
);
};
}
22 changes: 22 additions & 0 deletions src/platform/licensing/wingback-subscription/wingback.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { LicenseEntitlementType } from '@common/enums/license.entitlement.type';

export enum WingbackFeatureNames {
ACCOUNT_SPACE_FREE = 'ACCOUNT_SPACE_FREE',
ACCOUNT_VIRTUAL_CONTRIBUTOR = 'ACCOUNT_VIRTUAL_CONTRIBUTOR',
ACCOUNT_INNOVATION_HUB = 'ACCOUNT_INNOVATION_HUB',
ACCOUNT_INNOVATION_PACK = 'ACCOUNT_INNOVATION_PACK',
}

export const WingbackFeatureMapping: Record<
WingbackFeatureNames,
LicenseEntitlementType
> = {
[WingbackFeatureNames.ACCOUNT_SPACE_FREE]:
LicenseEntitlementType.ACCOUNT_SPACE_FREE,
[WingbackFeatureNames.ACCOUNT_VIRTUAL_CONTRIBUTOR]:
LicenseEntitlementType.ACCOUNT_VIRTUAL_CONTRIBUTOR,
[WingbackFeatureNames.ACCOUNT_INNOVATION_HUB]:
LicenseEntitlementType.ACCOUNT_INNOVATION_HUB,
[WingbackFeatureNames.ACCOUNT_INNOVATION_PACK]:
LicenseEntitlementType.ACCOUNT_INNOVATION_PACK,
};
hero101 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
WingbackFeature,
WingbackFeatureDetails,
WingbackTypedFeature,
} from '../wingback.type.feature';

export type WingbackFeatureDetailPerUnit = {
pricing_strategy: 'per_unit';
/** Number of units purchased */
contracted_unit_count: string;
/** Name of the unit - seats, computers, slots, etc */
unit_name: string;
/** If reported - specifies the number of units used*/
used_unit_count: string;
/** Minimum number of units that is possible to purchase */
minimum_units: string;
/** Minimum number of units that is possible to purchase */
maximum_units: string | null;
};

export const isWingbackFeatureDetailPerUnit = (
detail: WingbackFeatureDetails
): detail is WingbackFeatureDetailPerUnit => {
return detail.pricing_strategy === 'per_unit';
};

export const isWingbackFeaturePerUnit = (
feature: WingbackFeature
): feature is WingbackTypedFeature<WingbackFeatureDetailPerUnit> => {
return isWingbackFeatureDetailPerUnit(feature.entitlement_details);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ export type CreateCustomer = {
name: string;
/** Customer's emails */
emails: {
/** Main email */
main: string;
/** Secondary email (optional) */
secondary?: string;
/** Where the invoices are going to be sent */
invoice: string;
/** The organization email in Alkemio */
orgEmail?: string;
};
/** Customer's address (optional) */
address?: {
Expand All @@ -29,9 +29,11 @@ export type CreateCustomer = {
vat_id: string;
};
/** Notes about the customer */
notes: string;
notes?: string;
/** Customer reference */
customer_reference: string;
/** Customer's contracts (optional) */
contracts?: unknown[];
/** Additional metadata for the customer */
metadata?: Record<string, any>;
};
Loading