diff --git a/CHANGELOG.md b/CHANGELOG.md index 267dc2ab..1d46e375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- add an authentication metric to check if the access is authenticated + ## [0.43.2] - 2023-11-03 ### Fixed diff --git a/graphql/directives.graphql b/graphql/directives.graphql index beb82eb4..1af813c0 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 @checkUserAccess 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 2acdfbc1..d90a5ed0 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -17,16 +17,18 @@ type Query { pageSize: Int = 25 sortOrder: String = "ASC" sortedBy: String = "name" - ): OrganizationResult @cacheControl(scope: PUBLIC, maxAge: SHORT) + ): OrganizationResult @cacheControl(scope: PUBLIC, maxAge: SHORT) @auditAccess getOrganizationsWithoutSalesManager: [Organization] @cacheControl(scope: PRIVATE) getOrganizationById(id: ID): Organization @cacheControl(scope: PUBLIC, maxAge: SHORT) + @auditAccess getOrganizationByIdStorefront(id: ID): Organization @withSession @cacheControl(scope: PRIVATE) + @auditAccess getCostCenters( search: String page: Int = 1 @@ -41,7 +43,7 @@ type Query { pageSize: Int = 25 sortOrder: String = "ASC" sortedBy: String = "name" - ): CostCenterResult @cacheControl(scope: PUBLIC, maxAge: SHORT) + ): CostCenterResult @cacheControl(scope: PUBLIC, maxAge: SHORT) @auditAccess getCostCentersByOrganizationIdStorefront( id: ID search: String @@ -52,9 +54,11 @@ type Query { ): CostCenterResult @withSession @cacheControl(scope: PRIVATE) getCostCenterById(id: ID!): CostCenter @cacheControl(scope: PUBLIC, maxAge: SHORT) + @auditAccess getCostCenterByIdStorefront(id: ID): CostCenter @withSession @cacheControl(scope: PRIVATE, maxAge: SHORT) + @auditAccess getUsers(organizationId: ID, costCenterId: ID): [B2BUser] @withSession @checkUserAccess @@ -71,20 +75,29 @@ type Query { @withSession @checkUserAccess @cacheControl(scope: PRIVATE, maxAge: SHORT) - getPaymentTerms: [PaymentTerm] @cacheControl(scope: PUBLIC, maxAge: SHORT) + getPaymentTerms: [PaymentTerm] + @cacheControl(scope: PUBLIC, maxAge: SHORT) + @auditAccess getOrganizationsByEmail(email: String): [B2BOrganization] @checkUserAccess @cacheControl(scope: PRIVATE) + @auditAccess checkOrganizationIsActive(id: String): Boolean @cacheControl(scope: PRIVATE) - getSalesChannels: [Channels] @cacheControl(scope: PUBLIC, maxAge: SHORT) + getSalesChannels: [Channels] + @cacheControl(scope: PUBLIC, maxAge: SHORT) + @auditAccess getBinding(email: String!): Boolean @withSession @cacheControl(scope: PUBLIC, maxAge: SHORT) + @auditAccess getMarketingTags(costId: ID!): MarketingTags @cacheControl(scope: PUBLIC, maxAge: SHORT) - getB2BSettings: B2BSettings @cacheControl(scope: PUBLIC, maxAge: SHORT) + @auditAccess + getB2BSettings: B2BSettings + @cacheControl(scope: PUBLIC, maxAge: SHORT) + @auditAccess getSellers: [Seller] @checkAdminAccess @cacheControl(scope: PRIVATE) } @@ -93,7 +106,7 @@ type Mutation { createOrganizationRequest( input: OrganizationInput! notifyUsers: Boolean - ): MasterDataResponse + ): MasterDataResponse @auditAccess updateOrganizationRequest( id: ID! status: String! diff --git a/node/resolvers/directives.ts b/node/resolvers/directives.ts index b84e3f80..b1e6af1a 100644 --- a/node/resolvers/directives.ts +++ b/node/resolvers/directives.ts @@ -2,10 +2,12 @@ import { WithSession } from './directives/withSession' import { WithPermissions } from './directives/withPermissions' import { CheckAdminAccess } from './directives/checkAdminAccess' import { CheckUserAccess } from './directives/checkUserAccess' +import { AuditAccess } from './directives/auditAccess' export const schemaDirectives = { checkAdminAccess: CheckAdminAccess as any, checkUserAccess: CheckUserAccess as any, withPermissions: WithPermissions as any, withSession: WithSession 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 00000000..a5736883 --- /dev/null +++ b/node/resolvers/directives/auditAccess.ts @@ -0,0 +1,69 @@ +import type { GraphQLField } from 'graphql' +import { defaultFieldResolver } from 'graphql' +import { SchemaDirectiveVisitor } from 'graphql-tools' + +import sendAuthMetric, { AuthMetric } from '../../utils/metrics/auth' +import { getUserPermission } from './withPermissions' + +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 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) + } +} diff --git a/node/resolvers/directives/withPermissions.ts b/node/resolvers/directives/withPermissions.ts index ed10ef2f..6ab2305d 100644 --- a/node/resolvers/directives/withPermissions.ts +++ b/node/resolvers/directives/withPermissions.ts @@ -4,6 +4,16 @@ import type { GraphQLField } from 'graphql' import { defaultFieldResolver } from 'graphql' import { SchemaDirectiveVisitor } from 'graphql-tools' +import type StorefrontPermissions from '../../clients/storefrontPermissions' + +export const getUserPermission = async ( + storefrontPermissions: StorefrontPermissions +) => { + const result = await storefrontPermissions.checkUserPermission() + + return result?.data?.checkUserPermission ?? null +} + export class WithPermissions extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field @@ -21,21 +31,18 @@ export class WithPermissions extends SchemaDirectiveVisitor { const appClients = context.vtex as any - appClients.storefrontPermissions = await storefrontPermissions - .checkUserPermission() - .then((result: any) => { - return result?.data?.checkUserPermission ?? null - }) - .catch((error: any) => { - if (!adminUserAuthToken) { - logger.error({ - message: 'getPermissionsError', - error, - }) - } - - return null - }) + appClients.storefrontPermissions = await getUserPermission( + storefrontPermissions + ).catch((error: any) => { + if (!adminUserAuthToken) { + logger.error({ + message: 'getPermissionsError', + error, + }) + } + + return null + }) return resolve(root, args, context, info) } diff --git a/node/utils/metrics/auth.ts b/node/utils/metrics/auth.ts new file mode 100644 index 00000000..fdf32c0c --- /dev/null +++ b/node/utils/metrics/auth.ts @@ -0,0 +1,43 @@ +import type { Logger } from '@vtex/api/lib/service/logger/logger' + +import type { Metric } from './metrics' +import { B2B_METRIC_NAME, sendMetric } from './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-organization-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/utils/metrics/user.ts b/node/utils/metrics/user.ts index 8a3a12eb..664d56f7 100644 --- a/node/utils/metrics/user.ts +++ b/node/utils/metrics/user.ts @@ -1,8 +1,8 @@ import type { Logger } from '@vtex/api/lib/service/logger/logger' +import type { UserArgs } from '../../typings' import type { Metric } from './metrics' import { B2B_METRIC_NAME, sendMetric } from './metrics' -import type { UserArgs } from '../../typings' interface UserMetricType { description: string