Skip to content

Commit

Permalink
fix: update admin token validation and add audit metric (#165)
Browse files Browse the repository at this point in the history
  • Loading branch information
enzomerca authored Jul 22, 2024
1 parent cb328e9 commit ad8cb9e
Show file tree
Hide file tree
Showing 11 changed files with 201 additions and 45 deletions.
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
- Audit metrics for some graphql APIs
- Improve access directives

## [0.51.0] - 2024-06-04

### Fixed
Expand Down
28 changes: 19 additions & 9 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
type Query {
getAppSettings: SettingsResponse @cacheControl(scope: PUBLIC, maxAge: SHORT)
getAppSettings: SettingsResponse
@cacheControl(scope: PUBLIC, maxAge: SHORT)
@auditAccess
getOrganizationRequests(
status: [String]
search: String
page: Int = 1
pageSize: Int = 25
sortOrder: String = "DESC"
sortedBy: String = "created"
): OrganizationRequestResult @cacheControl(scope: PUBLIC, maxAge: SHORT)
): OrganizationRequestResult
@cacheControl(scope: PUBLIC, maxAge: SHORT)
@auditAccess
getOrganizationRequestById(id: ID!): OrganizationRequest
@cacheControl(scope: PUBLIC, maxAge: SHORT)
@auditAccess
getOrganizations(
status: [String]
search: String
Expand All @@ -23,6 +28,7 @@ type Query {

getOrganizationsWithoutSalesManager: [Organization]
@cacheControl(scope: PRIVATE)
@auditAccess

getOrganizationById(id: ID): Organization
@cacheControl(scope: PRIVATE)
Expand All @@ -37,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
getCostCentersByOrganizationId(
id: ID
search: String
Expand All @@ -55,7 +61,7 @@ type Query {
pageSize: Int = 25
sortOrder: String = "ASC"
sortedBy: String = "name"
): CostCenterResult @withSession @cacheControl(scope: PRIVATE)
): CostCenterResult @withSession @cacheControl(scope: PRIVATE) @auditAccess
getCostCenterById(id: ID!): CostCenter
@cacheControl(scope: PRIVATE, maxAge: SHORT)
@checkUserAccess
Expand Down Expand Up @@ -87,7 +93,9 @@ type Query {
@checkUserAccess
@cacheControl(scope: PRIVATE)

checkOrganizationIsActive(id: String): Boolean @cacheControl(scope: PRIVATE)
checkOrganizationIsActive(id: String): Boolean
@cacheControl(scope: PRIVATE)
@auditAccess
getSalesChannels: [Channels]
@cacheControl(scope: PUBLIC, maxAge: SHORT)
@auditAccess
Expand All @@ -105,18 +113,18 @@ type Query {
}

type Mutation {
saveAppSettings: MutationResponse @cacheControl(scope: PRIVATE)
saveAppSettings: MutationResponse @cacheControl(scope: PRIVATE) @auditAccess
createOrganizationRequest(
input: OrganizationInput!
notifyUsers: Boolean
): MasterDataResponse
): MasterDataResponse @auditAccess
updateOrganizationRequest(
id: ID!
status: String!
notes: String
notifyUsers: Boolean
): MutationResponse
deleteOrganizationRequest(id: ID!): MutationResponse
): MutationResponse @auditAccess
deleteOrganizationRequest(id: ID!): MutationResponse @auditAccess
createOrganization(
input: OrganizationInput!
notifyUsers: Boolean
Expand Down Expand Up @@ -266,10 +274,12 @@ type Mutation {
@withSession
@withPermissions
@cacheControl(scope: PRIVATE)
@auditAccess
impersonateB2BUser(id: ID!): MutationResponse
@withSession
@withPermissions
@cacheControl(scope: PRIVATE)
@auditAccess
saveSalesChannels(channels: [SalesChannelsInput]!): MutationResponse
@checkAdminAccess
@cacheControl(scope: PRIVATE)
Expand Down
30 changes: 30 additions & 0 deletions node/clients/LMClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { InstanceOptions, IOContext } from '@vtex/api'
import { ExternalClient } from '@vtex/api'

export default class LMClient extends ExternalClient {
constructor(ctx: IOContext, options?: InstanceOptions) {
super(`http://${ctx.account}.vtexcommercestable.com.br/`, ctx, {
...options,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
VtexIdclientAutCookie: ctx.authToken,
},
})
}

public getUserAdminPermissions = async (account: string, userId: string) => {
return this.get(
`/api/license-manager/pvt/accounts/${encodeURI(
account
)}/logins/${encodeURI(userId)}/granted`
).then((res: any) => {
return res
})
}

protected get = <T>(url: string) => {
return this.http.get<T>(url)
}
}
5 changes: 5 additions & 0 deletions node/clients/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IOClients } from '@vtex/api'

import LMClient from './LMClient'
import AnalyticsClient from './analytics'
import VtexId from './vtexId'
import PaymentsClient from './payments'
Expand All @@ -14,6 +15,10 @@ import SellersClient from './sellers'

// Extend the default IOClients implementation with our own custom clients.
export class Clients extends IOClients {
public get lm() {
return this.getOrSet('lm', LMClient)
}

public get analytics() {
return this.getOrSet('analytics', AnalyticsClient)
}
Expand Down
4 changes: 2 additions & 2 deletions node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.51.0",
"dependencies": {
"@types/lodash": "4.14.74",
"@vtex/api": "6.46.1",
"@vtex/api": "6.47.0",
"atob": "^2.1.2",
"co-body": "^6.0.0",
"graphql": "^14.5.0",
Expand All @@ -20,7 +20,7 @@
"@types/jsonwebtoken": "^8.5.0",
"@types/node": "^12.12.21",
"@types/ramda": "types/npm-ramda#dist",
"@vtex/api": "6.46.1",
"@vtex/api": "6.47.0",
"@vtex/prettier-config": "^0.3.1",
"@vtex/tsconfig": "^0.6.0",
"jest": "27.5.1",
Expand Down
55 changes: 36 additions & 19 deletions node/resolvers/directives/auditAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { defaultFieldResolver } from 'graphql'
import { SchemaDirectiveVisitor } from 'graphql-tools'

import sendAuthMetric, { AuthMetric } from '../../utils/metrics/auth'
import {
validateAdminToken,
validateAdminTokenOnHeader,
validateApiToken,
validateStoreToken,
} from './helper'

export class AuditAccess extends SchemaDirectiveVisitor {
public visitFieldDefinition(field: GraphQLField<any, any>) {
Expand All @@ -22,36 +28,47 @@ export class AuditAccess extends SchemaDirectiveVisitor {

private async sendAuthMetric(field: GraphQLField<any, any>, context: any) {
const {
vtex: { adminUserAuthToken, storeUserAuthToken, account, logger },
request,
vtex: { adminUserAuthToken, storeUserAuthToken, logger },
} = context

const userAgent = request.headers['user-agent'] as string
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 { hasAdminToken, hasValidAdminToken } = await validateAdminToken(
context,
adminUserAuthToken as string
)

const hasStoreToken = !!storeUserAuthToken
const hasApiToken = !!request.headers['vtex-api-apptoken']
const { hasAdminTokenOnHeader, hasValidAdminTokenOnHeader } =
await validateAdminTokenOnHeader(context)

const authMetric = new AuthMetric(account, {
const { hasApiToken, hasValidApiToken } = await validateApiToken(context)

const { hasStoreToken, hasValidStoreToken } = await validateStoreToken(
context,
storeUserAuthToken as string
)

// now we emit a metric with all the collected data before we proceed
const operation = field?.astNode?.name?.value ?? context?.request?.url
const userAgent = context?.request?.headers['user-agent'] as string
const caller = context?.request?.headers['x-vtex-caller'] as string
const forwardedHost = context?.request?.headers[
'x-forwarded-host'
] as string

const auditMetric = new AuthMetric(context?.vtex?.account, {
operation,
forwardedHost,
caller,
userAgent,
forwardedHost,
hasAdminToken,
hasValidAdminToken,
hasApiToken,
hasValidApiToken,
hasStoreToken,
operation,
hasValidStoreToken,
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
})

await sendAuthMetric(context, logger, authMetric)
await sendAuthMetric(context, logger, auditMetric)
}
}
26 changes: 23 additions & 3 deletions node/resolvers/directives/checkAdminAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { GraphQLField } from 'graphql'
import { defaultFieldResolver } from 'graphql'

import sendAuthMetric, { AuthMetric } from '../../utils/metrics/auth'
import { validateAdminToken, validateApiToken } from './helper'
import {
validateAdminToken,
validateAdminTokenOnHeader,
validateApiToken,
} from './helper'

export class CheckAdminAccess extends SchemaDirectiveVisitor {
public visitFieldDefinition(field: GraphQLField<any, any>) {
Expand All @@ -23,6 +27,12 @@ export class CheckAdminAccess extends SchemaDirectiveVisitor {
const { hasAdminToken, hasValidAdminToken, hasCurrentValidAdminToken } =
await validateAdminToken(context, adminUserAuthToken as string)

const {
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
hasCurrentValidAdminTokenOnHeader,
} = await validateAdminTokenOnHeader(context)

const { hasApiToken, hasValidApiToken, hasCurrentValidApiToken } =
await validateApiToken(context)

Expand All @@ -48,13 +58,15 @@ export class CheckAdminAccess extends SchemaDirectiveVisitor {
hasApiToken,
hasValidApiToken,
hasStoreToken,
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
},
'CheckAdminAccessAudit'
)

sendAuthMetric(context, logger, auditMetric)

if (!hasAdminToken && !hasApiToken) {
if (!hasAdminToken && !hasApiToken && !hasAdminTokenOnHeader) {
logger.warn({
message: 'CheckAdminAccess: No token provided',
userAgent,
Expand All @@ -66,11 +78,17 @@ export class CheckAdminAccess extends SchemaDirectiveVisitor {
hasApiToken,
hasValidApiToken,
hasStoreToken,
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
})
throw new AuthenticationError('No token was provided')
}

if (!hasCurrentValidAdminToken && !hasCurrentValidApiToken) {
if (
!hasCurrentValidAdminToken &&
!hasCurrentValidApiToken &&
!hasCurrentValidAdminTokenOnHeader
) {
logger.warn({
message: 'CheckAdminAccess: Invalid token',
userAgent,
Expand All @@ -82,6 +100,8 @@ export class CheckAdminAccess extends SchemaDirectiveVisitor {
hasApiToken,
hasValidApiToken,
hasStoreToken,
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
})
throw new ForbiddenError('Unauthorized Access')
}
Expand Down
23 changes: 21 additions & 2 deletions node/resolvers/directives/checkUserAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SchemaDirectiveVisitor } from 'graphql-tools'
import sendAuthMetric, { AuthMetric } from '../../utils/metrics/auth'
import {
validateAdminToken,
validateAdminTokenOnHeader,
validateApiToken,
validateStoreToken,
} from './helper'
Expand All @@ -27,6 +28,12 @@ export class CheckUserAccess extends SchemaDirectiveVisitor {
const { hasAdminToken, hasValidAdminToken, hasCurrentValidAdminToken } =
await validateAdminToken(context, adminUserAuthToken as string)

const {
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
hasCurrentValidAdminTokenOnHeader,
} = await validateAdminTokenOnHeader(context)

const { hasApiToken, hasValidApiToken, hasCurrentValidApiToken } =
await validateApiToken(context)

Expand Down Expand Up @@ -54,6 +61,8 @@ export class CheckUserAccess extends SchemaDirectiveVisitor {
hasValidApiToken,
hasStoreToken,
hasValidStoreToken,
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
},
'CheckUserAccessAudit'
)
Expand All @@ -73,7 +82,12 @@ export class CheckUserAccess extends SchemaDirectiveVisitor {
return resolve(root, args, context, info)
}

if (!hasAdminToken && !hasStoreToken && !hasApiToken) {
if (
!hasAdminToken &&
!hasStoreToken &&
!hasApiToken &&
!hasAdminTokenOnHeader
) {
logger.warn({
message: 'CheckUserAccess: No token provided',
userAgent,
Expand All @@ -85,14 +99,17 @@ export class CheckUserAccess extends SchemaDirectiveVisitor {
hasApiToken,
hasValidApiToken,
hasStoreToken,
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
})
throw new AuthenticationError('No token was provided')
}

if (
!hasCurrentValidAdminToken &&
!hasCurrentValidStoreToken &&
!hasCurrentValidApiToken
!hasCurrentValidApiToken &&
!hasCurrentValidAdminTokenOnHeader
) {
logger.warn({
message: `CheckUserAccess: Invalid token`,
Expand All @@ -106,6 +123,8 @@ export class CheckUserAccess extends SchemaDirectiveVisitor {
hasValidApiToken,
hasStoreToken,
hasValidStoreToken,
hasAdminTokenOnHeader,
hasValidAdminTokenOnHeader,
})
throw new ForbiddenError('Unauthorized Access')
}
Expand Down
Loading

0 comments on commit ad8cb9e

Please sign in to comment.