diff --git a/CHANGELOG.md b/CHANGELOG.md index ac726f2..ff48150 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 + ## [1.36.0] - 2023-08-09 ### Added diff --git a/graphql/directives.graphql b/graphql/directives.graphql index 6ac7146..7a3de32 100644 --- a/graphql/directives.graphql +++ b/graphql/directives.graphql @@ -3,3 +3,4 @@ directive @checkAdminAccess on FIELD | FIELD_DEFINITION directive @withSession on FIELD_DEFINITION directive @withSender on FIELD_DEFINITION directive @withUserPermissions on FIELD_DEFINITION +directive @auditAccess on FIELD | FIELD_DEFINITION diff --git a/graphql/schema.graphql b/graphql/schema.graphql index a35c59a..bc4c7fd 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -2,6 +2,8 @@ type Query { getAppSettings: SettingsResponse @cacheControl(scope: PRIVATE) @settings(settingsType: "workspace") + @withSender + @auditAccess getRole(id: ID!): Role @cacheControl(scope: PRIVATE, maxAge: SHORT) @@ -11,6 +13,8 @@ type Query { @cacheControl(scope: PRIVATE, maxAge: SHORT) @settings(settingsType: "workspace") @withSession + @withSender + @auditAccess getFeaturesByModule(module: String!): Feature @settings(settingsType: "workspace") @@ -19,15 +23,26 @@ type Query { listFeatures: [FullFeature] @settings(settingsType: "workspace") @cacheControl(scope: PRIVATE, maxAge: SHORT) + @withSender + @auditAccess getUser(id: ID!): User @cacheControl(scope: PRIVATE) getB2BUser(id: ID!): User @cacheControl(scope: PRIVATE) - checkCustomerSchema: Boolean @cacheControl(scope: PRIVATE) + checkCustomerSchema: Boolean + @cacheControl(scope: PRIVATE) + @withSender + @auditAccess - getUserByEmail(email: String!): [User] @cacheControl(scope: PRIVATE) + getUserByEmail(email: String!): [User] + @cacheControl(scope: PRIVATE) + @withSender + @auditAccess - listAllUsers: [User] @cacheControl(scope: PRIVATE, maxAge: SHORT) + listAllUsers: [User] + @cacheControl(scope: PRIVATE, maxAge: SHORT) + @withSender + @auditAccess listUsers(organizationId: ID, costCenterId: ID, roleId: ID): [User] @cacheControl(scope: PRIVATE, maxAge: SHORT) @@ -75,9 +90,12 @@ type Mutation { name: String! slug: String features: [FeatureInput] - ): MutationResponse @cacheControl(scope: PRIVATE) + ): MutationResponse @cacheControl(scope: PRIVATE) @withSender @auditAccess - deleteRole(id: ID!): MutationResponse @cacheControl(scope: PRIVATE) + deleteRole(id: ID!): MutationResponse + @cacheControl(scope: PRIVATE) + @withSender + @auditAccess saveUser( id: ID @@ -140,6 +158,8 @@ type Mutation { setCurrentOrganization(orgId: ID!, costId: ID!): MutationResponse @withSession @cacheControl(scope: PRIVATE) + @withSender + @auditAccess } type UserImpersonation { 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/directives/auditAccess.ts b/node/directives/auditAccess.ts new file mode 100644 index 0000000..b6a6c8f --- /dev/null +++ b/node/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 '../metrics/auth' +import { checkUserPermission } from '../resolvers/Queries/Users' + +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 { + 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.vtex.sender ?? (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 checkUserPermission( + null, + { skipError: true }, + context + ) + + 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/directives/index.ts b/node/directives/index.ts index 9d7f577..35b6665 100644 --- a/node/directives/index.ts +++ b/node/directives/index.ts @@ -3,6 +3,7 @@ import { WithSender } from './withSender' import { WithUserPermissions } from './withUserPermissions' import { CheckAdminAccess } from './checkAdminAccess' import { CheckUserAccess } from './checkUserAccess' +import { AuditAccess } from './auditAccess' export const schemaDirectives = { checkAdminAccess: CheckAdminAccess as any, @@ -10,4 +11,5 @@ export const schemaDirectives = { withSession: WithSession, withSender: WithSender, withUserPermissions: WithUserPermissions, + auditAccess: AuditAccess as any, } diff --git a/node/metrics/auth.ts b/node/metrics/auth.ts new file mode 100644 index 0000000..1a115df --- /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-storefront-permissions-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/changeTeam.ts b/node/utils/metrics/changeTeam.ts index f8a04d0..553fba9 100644 --- a/node/utils/metrics/changeTeam.ts +++ b/node/utils/metrics/changeTeam.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 ChangeTeamFieldsMetric = { date: string @@ -10,7 +10,20 @@ type ChangeTeamFieldsMetric = { new_cost_center_id: string } -type ChangeTeamMetric = Metric & { fields: ChangeTeamFieldsMetric } +export class ChangeTeamMetric implements Metric { + public readonly description: string + public readonly kind: string + public readonly account: string + public readonly fields: ChangeTeamFieldsMetric + public readonly name = B2B_METRIC_NAME + + constructor(account: string, fields: ChangeTeamFieldsMetric) { + this.account = account + this.fields = fields + this.kind = 'change-team-graphql-event' + this.description = 'User change team/organization - Graphql' + } +} export type ChangeTeamParams = { account: string @@ -22,20 +35,14 @@ export type ChangeTeamParams = { } const buildMetric = (metricParams: ChangeTeamParams): ChangeTeamMetric => { - return { - name: 'b2b-suite-buyerorg-data' as const, - account: metricParams.account, - kind: 'change-team-graphql-event', - description: 'User change team/organization - Graphql', - fields: { - date: new Date().toISOString(), - user_id: metricParams.userId, - user_role: metricParams.userRole, - user_email: metricParams.userEmail, - new_org_id: metricParams.orgId, - new_cost_center_id: metricParams.costCenterId, - }, - } + return new ChangeTeamMetric(metricParams.account, { + date: new Date().toISOString(), + user_id: metricParams.userId, + user_role: metricParams.userRole, + user_email: metricParams.userEmail, + new_org_id: metricParams.orgId, + new_cost_center_id: metricParams.costCenterId, + }) } export const sendChangeTeamMetric = async (metricParams: ChangeTeamParams) => { diff --git a/node/utils/metrics/metrics.ts b/node/utils/metrics/metrics.ts deleted file mode 100644 index 07c4e32..0000000 --- a/node/utils/metrics/metrics.ts +++ /dev/null @@ -1,21 +0,0 @@ -import axios from 'axios' - -const ANALYTICS_URL = 'https://rc.vtex.com/api/analytics/schemaless-events' - -export type ChangeTeamMetric = { - kind: 'change-team-graphql-event' - description: 'User change team/organization - Graphql' -} - -export type Metric = { - name: 'b2b-suite-buyerorg-data' - account: string -} & ChangeTeamMetric - -export const sendMetric = async (metric: Metric) => { - try { - await axios.post(ANALYTICS_URL, metric) - } catch (error) { - console.warn('Unable to log metrics', error) - } -}