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

feat: add audit metrics for authenticated access #118

Merged
merged 8 commits into from
Nov 6, 2023
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions graphql/directives.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 13 additions & 5 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ type Query {
getAppSettings: SettingsResponse
@cacheControl(scope: PRIVATE)
@settings(settingsType: "workspace")
@auditAccess

getRole(id: ID!): Role @cacheControl(scope: PRIVATE, maxAge: SHORT)

Expand All @@ -11,6 +12,7 @@ type Query {
@cacheControl(scope: PRIVATE, maxAge: SHORT)
@settings(settingsType: "workspace")
@withSession
@auditAccess

getFeaturesByModule(module: String!): Feature
@settings(settingsType: "workspace")
Expand All @@ -19,15 +21,18 @@ type Query {
listFeatures: [FullFeature]
@settings(settingsType: "workspace")
@cacheControl(scope: PRIVATE, maxAge: SHORT)
@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) @auditAccess

getUserByEmail(email: String!): [User] @cacheControl(scope: PRIVATE)
getUserByEmail(email: String!): [User]
@cacheControl(scope: PRIVATE)
@auditAccess

listAllUsers: [User] @cacheControl(scope: PRIVATE, maxAge: SHORT)
listAllUsers: [User] @cacheControl(scope: PRIVATE, maxAge: SHORT) @auditAccess

listUsers(organizationId: ID, costCenterId: ID, roleId: ID): [User]
@cacheControl(scope: PRIVATE, maxAge: SHORT)
Expand Down Expand Up @@ -75,9 +80,11 @@ type Mutation {
name: String!
slug: String
features: [FeatureInput]
): MutationResponse @cacheControl(scope: PRIVATE)
): MutationResponse @cacheControl(scope: PRIVATE) @auditAccess

deleteRole(id: ID!): MutationResponse @cacheControl(scope: PRIVATE)
deleteRole(id: ID!): MutationResponse
@cacheControl(scope: PRIVATE)
@auditAccess

saveUser(
id: ID
Expand Down Expand Up @@ -140,6 +147,7 @@ type Mutation {
setCurrentOrganization(orgId: ID!, costId: ID!): MutationResponse
@withSession
@cacheControl(scope: PRIVATE)
@auditAccess
}

type UserImpersonation {
Expand Down
16 changes: 16 additions & 0 deletions node/clients/metrics.ts
Original file line number Diff line number Diff line change
@@ -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)
}
69 changes: 69 additions & 0 deletions node/directives/auditAccess.ts
Original file line number Diff line number Diff line change
@@ -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<any, any>) {
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<any, any>, 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)
}
}
2 changes: 2 additions & 0 deletions node/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ 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,
checkUserAccess: CheckUserAccess as any,
withSession: WithSession,
withSender: WithSender,
withUserPermissions: WithUserPermissions,
auditAccess: AuditAccess as any,
}
43 changes: 43 additions & 0 deletions node/metrics/auth.ts
Original file line number Diff line number Diff line change
@@ -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
24 changes: 17 additions & 7 deletions node/utils/metrics/changeTeam.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -23,10 +36,7 @@ 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,
Expand All @@ -35,7 +45,7 @@ const buildMetric = (metricParams: ChangeTeamParams): ChangeTeamMetric => {
new_org_id: metricParams.orgId,
new_cost_center_id: metricParams.costCenterId,
},
}
} as ChangeTeamMetric
}

export const sendChangeTeamMetric = async (metricParams: ChangeTeamParams) => {
Expand Down
21 changes: 0 additions & 21 deletions node/utils/metrics/metrics.ts

This file was deleted.

Loading