diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c3793..4e0bbdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- add an authentication metric to check if the access is authenticated + ## [2.3.1] - 2023-09-13 ### Fixed diff --git a/graphql/directives.graphql b/graphql/directives.graphql index 8863708..3650692 100644 --- a/graphql/directives.graphql +++ b/graphql/directives.graphql @@ -2,3 +2,4 @@ directive @withSession on FIELD | FIELD_DEFINITION directive @withPermissions on FIELD | FIELD_DEFINITION directive @withSegment on FIELD | FIELD_DEFINITION directive @checkAdminAccess on FIELD | FIELD_DEFINITION +directive @auditAccess on FIELD | FIELD_DEFINITION diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 9ee0335..c063579 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -22,13 +22,16 @@ type Query { } type Mutation { - createQuote(input: QuoteInput!): String @withPermissions @withSession @withSegment + createQuote(input: QuoteInput!): String + @withPermissions + @withSession + @withSegment updateQuote(input: QuoteUpdateInput!): String @withPermissions @withSession useQuote(id: String, orderFormId: String): String @withPermissions @withSession - clearCart(orderFormId: String): String - saveAppSettings(input: AppSettingsInput!): AppSettings - @cacheControl(scope: PRIVATE) + clearCart(orderFormId: String): String @auditAccess + saveAppSettings(input: AppSettingsInput!): AppSettings + @cacheControl(scope: PRIVATE) @checkAdminAccess } diff --git a/node/clients/metrics.ts b/node/clients/metrics.ts new file mode 100644 index 0000000..df2d8ec --- /dev/null +++ b/node/clients/metrics.ts @@ -0,0 +1,16 @@ +import axios from 'axios' + +const ANALYTICS_URL = 'https://rc.vtex.com/api/analytics/schemaless-events' + +export const B2B_METRIC_NAME = 'b2b-suite-buyerorg-data' + +export interface Metric { + readonly account: string + readonly kind: string + readonly description: string + readonly name: typeof B2B_METRIC_NAME +} + +export const sendMetric = async (metric: Metric) => { + await axios.post(ANALYTICS_URL, metric) +} diff --git a/node/metrics/auth.ts b/node/metrics/auth.ts new file mode 100644 index 0000000..1a01a80 --- /dev/null +++ b/node/metrics/auth.ts @@ -0,0 +1,43 @@ +import type { Logger } from '@vtex/api/lib/service/logger/logger' + +import type { Metric } from '../clients/metrics' +import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics' + +export interface AuthAuditMetric { + operation: string + forwardedHost: string + caller: string + role?: string + permissions?: string[] + hasAdminToken: boolean + hasStoreToken: boolean + hasApiToken: boolean +} + +export class AuthMetric implements Metric { + public readonly description: string + public readonly kind: string + public readonly account: string + public readonly fields: AuthAuditMetric + public readonly name = B2B_METRIC_NAME + + constructor(account: string, fields: AuthAuditMetric) { + this.account = account + this.fields = fields + this.kind = 'b2b-quotes-graphql-auth-event' + this.description = 'Auth metric event' + } +} + +const sendAuthMetric = async (logger: Logger, authMetric: AuthMetric) => { + try { + await sendMetric(authMetric) + } catch (error) { + logger.error({ + error, + message: `Error to send metrics from auth metric`, + }) + } +} + +export default sendAuthMetric diff --git a/node/metrics/createQuote.ts b/node/metrics/createQuote.ts index 9d0ad11..b421241 100644 --- a/node/metrics/createQuote.ts +++ b/node/metrics/createQuote.ts @@ -1,5 +1,5 @@ -import type { Metric } from './metrics' -import { sendMetric } from './metrics' +import type { Metric } from '../clients/metrics' +import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics' type UserData = { orgId: string @@ -39,7 +39,20 @@ type CreateQuoteFieldsMetric = { send_to_sales_rep: boolean } -type CreateQuoteMetric = Metric & { fields: CreateQuoteFieldsMetric } +export class CreateQuoteMetric implements Metric { + public readonly description: string + public readonly kind: string + public readonly account: string + public readonly fields: CreateQuoteFieldsMetric + public readonly name = B2B_METRIC_NAME + + constructor(account: string, fields: CreateQuoteFieldsMetric) { + this.account = account + this.fields = fields + this.kind = 'create-quote-graphql-event' + this.description = 'Create Quotation Action - Graphql' + } +} const buildQuoteMetric = ( metricsParam: CreateQuoteMetricParam @@ -48,24 +61,16 @@ const buildQuoteMetric = ( const accountName = namespaces?.account?.accountName?.value const userEmail = namespaces?.profile?.email?.value - const metric: CreateQuoteMetric = { - name: 'b2b-suite-buyerorg-data', - kind: 'create-quote-graphql-event', - description: 'Create Quotation Action - Graphql', - account: accountName, - fields: { - buyer_org_id: metricsParam.userData?.orgId, - cost_center_id: metricsParam.userData?.costId, - member_email: userEmail, - role: metricsParam.userData?.roleId, - creation_date: metricsParam.creationDate, - quote_id: metricsParam.quoteId, - quote_reference_name: metricsParam.quoteReferenceName, - send_to_sales_rep: metricsParam.sendToSalesRep, - }, - } - - return metric + return new CreateQuoteMetric(accountName, { + buyer_org_id: metricsParam.userData?.orgId, + cost_center_id: metricsParam.userData?.costId, + member_email: userEmail, + role: metricsParam.userData?.roleId, + creation_date: metricsParam.creationDate, + quote_id: metricsParam.quoteId, + quote_reference_name: metricsParam.quoteReferenceName, + send_to_sales_rep: metricsParam.sendToSalesRep, + }) } export const sendCreateQuoteMetric = async ( diff --git a/node/metrics/metrics.ts b/node/metrics/metrics.ts deleted file mode 100644 index 61c64c6..0000000 --- a/node/metrics/metrics.ts +++ /dev/null @@ -1,31 +0,0 @@ -import axios from 'axios' - -const ANALYTICS_URL = 'https://rc.vtex.com/api/analytics/schemaless-events' - -type CreateQuoteMetric = { - kind: 'create-quote-graphql-event' - description: 'Create Quotation Action - Graphql' -} - -type SendMessageMetric = { - kind: 'send-message-graphql-event' - description: 'Send Message Action - Graphql' -} - -type UseQuoteMetric = { - kind: 'use-quote-graphql-event' - description: 'Use Quotation Action - Graphql' -} - -export type Metric = { - name: 'b2b-suite-buyerorg-data' - account: string -} & (CreateQuoteMetric | SendMessageMetric | UseQuoteMetric) - -export const sendMetric = async (metric: Metric) => { - try { - await axios.post(ANALYTICS_URL, metric) - } catch (error) { - console.warn('Unable to log metrics', error) - } -} diff --git a/node/metrics/sendMessage.ts b/node/metrics/sendMessage.ts index 862919e..d8370ca 100644 --- a/node/metrics/sendMessage.ts +++ b/node/metrics/sendMessage.ts @@ -1,5 +1,5 @@ -import type { Metric } from './metrics' -import { sendMetric } from './metrics' +import type { Metric } from '../clients/metrics' +import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics' type Quote = { costCenter: string @@ -23,27 +23,32 @@ type SendMessageFieldsMetric = { sent_date: string } -type SendMessageMetric = Metric & { fields: SendMessageFieldsMetric } +export class SendMessageMetric implements Metric { + public readonly description: string + public readonly kind: string + public readonly account: string + public readonly fields: SendMessageFieldsMetric + public readonly name = B2B_METRIC_NAME + + constructor(account: string, fields: SendMessageFieldsMetric) { + this.account = account + this.fields = fields + this.kind = 'send-message-graphql-event' + this.description = 'Send Message Action - Graphql' + } +} const buildSendMessageMetric = ( metricParam: SendMessageMetricParam ): SendMessageMetric => { - const metric: SendMessageMetric = { - name: 'b2b-suite-buyerorg-data', - kind: 'send-message-graphql-event', - description: 'Send Message Action - Graphql', - account: metricParam.account, - fields: { - buyer_org_name: metricParam.quote?.organization, - cost_center_name: metricParam.quote?.costCenter, - quote_id: metricParam.quote?.id, - template_name: metricParam.templateName, - sent_to: metricParam.sentTo, - sent_date: new Date().toISOString(), - }, - } - - return metric + return new SendMessageMetric(metricParam.account, { + buyer_org_name: metricParam.quote?.organization, + cost_center_name: metricParam.quote?.costCenter, + quote_id: metricParam.quote?.id, + template_name: metricParam.templateName, + sent_to: metricParam.sentTo, + sent_date: new Date().toISOString(), + }) } export const sendMessageMetric = async ( diff --git a/node/metrics/useQuote.ts b/node/metrics/useQuote.ts index ff25ffa..928401c 100644 --- a/node/metrics/useQuote.ts +++ b/node/metrics/useQuote.ts @@ -1,5 +1,5 @@ -import type { Metric } from './metrics' -import { sendMetric } from './metrics' +import type { Metric } from '../clients/metrics' +import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics' type UseQuoteFieldsMetric = { quote_id: string @@ -14,7 +14,20 @@ type UseQuoteFieldsMetric = { quote_last_update: string } -type UseQuoteMetric = Metric & { fields: UseQuoteFieldsMetric } +export class UseQuoteMetric implements Metric { + public readonly description: string + public readonly kind: string + public readonly account: string + public readonly fields: UseQuoteFieldsMetric + public readonly name = B2B_METRIC_NAME + + constructor(account: string, fields: UseQuoteFieldsMetric) { + this.account = account + this.fields = fields + this.kind = 'use-quote-graphql-event' + this.description = 'Use Quotation Action - Graphql' + } +} export type UseQuoteMetricsParams = { quote: Quote @@ -28,26 +41,18 @@ const buildUseQuoteMetric = ( ): UseQuoteMetric => { const { quote, orderFormId, account, userEmail } = metricsParam - const metric: UseQuoteMetric = { - name: 'b2b-suite-buyerorg-data', - kind: 'use-quote-graphql-event', - description: 'Use Quotation Action - Graphql', - account, - fields: { - buyer_org_id: quote.organization, - cost_center_id: quote.costCenter, - quote_id: quote.id, - quote_reference_name: quote.referenceName, - order_form_id: orderFormId, - quote_creation_date: quote.creationDate, - quote_use_date: new Date().toISOString(), - creator_email: quote.creatorEmail, - user_email: userEmail, - quote_last_update: quote.lastUpdate, - }, - } - - return metric + return new UseQuoteMetric(account, { + buyer_org_id: quote.organization, + cost_center_id: quote.costCenter, + quote_id: quote.id, + quote_reference_name: quote.referenceName, + order_form_id: orderFormId, + quote_creation_date: quote.creationDate, + quote_use_date: new Date().toISOString(), + creator_email: quote.creatorEmail, + user_email: userEmail, + quote_last_update: quote.lastUpdate, + }) } export const sendUseQuoteMetric = async ( diff --git a/node/resolvers/directives.ts b/node/resolvers/directives.ts index 51e4141..d59bc60 100644 --- a/node/resolvers/directives.ts +++ b/node/resolvers/directives.ts @@ -2,10 +2,12 @@ import { WithPermissions } from './directives/withPermissions' import { WithSession } from './directives/withSession' import { WithSegment } from './directives/withSegment' import { CheckAdminAccess } from './directives/checkAdminAccess' +import { AuditAccess } from './directives/auditAccess' export const schemaDirectives = { withPermissions: WithPermissions as any, withSession: WithSession as any, withSegment: WithSegment as any, checkAdminAccess: CheckAdminAccess as any, + auditAccess: AuditAccess as any, } diff --git a/node/resolvers/directives/auditAccess.ts b/node/resolvers/directives/auditAccess.ts new file mode 100644 index 0000000..082494b --- /dev/null +++ b/node/resolvers/directives/auditAccess.ts @@ -0,0 +1,79 @@ +import type { GraphQLField } from 'graphql' +import { defaultFieldResolver } from 'graphql' +import { SchemaDirectiveVisitor } from 'graphql-tools' + +import type StorefrontPermissions from '../../clients/storefrontPermissions' +import sendAuthMetric, { AuthMetric } from '../../metrics/auth' + +export class AuditAccess extends SchemaDirectiveVisitor { + public visitFieldDefinition(field: GraphQLField) { + const { resolve = defaultFieldResolver } = field + + field.resolve = async ( + root: any, + args: any, + context: Context, + info: any + ) => { + this.sendAuthMetric(field, context) + + return resolve(root, args, context, info) + } + } + + private async sendAuthMetric(field: GraphQLField, context: any) { + const { + clients: { storefrontPermissions }, + vtex: { adminUserAuthToken, storeUserAuthToken, account, logger }, + request, + } = context + + const operation = field.astNode?.name?.value ?? request.url + const forwardedHost = request.headers['x-forwarded-host'] as string + const caller = + context?.graphql?.query?.senderApp ?? + context?.graphql?.query?.extensions?.persistedQuery?.sender ?? + request.header['x-b2b-senderapp'] ?? + (request.headers['x-vtex-caller'] as string) + + const hasAdminToken = !!( + adminUserAuthToken ?? (context?.headers.vtexidclientautcookie as string) + ) + + const hasStoreToken = !!storeUserAuthToken + const hasApiToken = !!request.headers['vtex-api-apptoken'] + + let role + let permissions + + if (hasAdminToken || hasStoreToken) { + const userPermissions = await this.getUserPermission( + storefrontPermissions + ) + + role = userPermissions?.role?.slug + permissions = userPermissions?.permissions + } + + const authMetric = new AuthMetric(account, { + caller, + forwardedHost, + hasAdminToken, + hasApiToken, + hasStoreToken, + operation, + permissions, + role, + }) + + await sendAuthMetric(logger, authMetric) + } + + private async getUserPermission( + storefrontPermissions: StorefrontPermissions + ) { + const result = await storefrontPermissions.checkUserPermission() + + return result?.data?.checkUserPermission ?? null + } +}