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 #134

Merged
merged 11 commits into from
Nov 6, 2023
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

## [0.43.1] - 2023-10-27

### Fixed
Expand Down
1 change: 1 addition & 0 deletions graphql/directives.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 19 additions & 6 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}

Expand All @@ -93,7 +106,7 @@ type Mutation {
createOrganizationRequest(
input: OrganizationInput!
notifyUsers: Boolean
): MasterDataResponse
): MasterDataResponse @auditAccess
updateOrganizationRequest(
id: ID!
status: String!
Expand Down
2 changes: 2 additions & 0 deletions node/resolvers/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
69 changes: 69 additions & 0 deletions node/resolvers/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 '../../utils/metrics/auth'
import { getUserPermission } from './withPermissions'

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 {
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)
}
}
37 changes: 22 additions & 15 deletions node/resolvers/directives/withPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any>) {
const { resolve = defaultFieldResolver } = field
Expand All @@ -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)
}
Expand Down
43 changes: 43 additions & 0 deletions node/utils/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 './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
Rudge marked this conversation as resolved.
Show resolved Hide resolved
}

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
2 changes: 1 addition & 1 deletion node/utils/metrics/user.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading