Skip to content

Commit

Permalink
Validate entity store domain on fetch (#185)
Browse files Browse the repository at this point in the history
  • Loading branch information
wcalderipe authored Mar 26, 2024
1 parent 0e0d8d5 commit fcfaf8b
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import nock from 'nock'
import { withTempJsonFile } from '../../../../../shared/testing/with-temp-json-file.testing'
import { FileSystemDataStoreRepository } from '../../../../persistence/repository/file-system-data-store.repository'
import { HttpDataStoreRepository } from '../../../../persistence/repository/http-data-store.repository'
import { DataStoreException } from '../../../exception/data-store.exception'
import { DataStoreRepositoryFactory } from '../../../factory/data-store-repository.factory'
import { DataStoreService } from '../../data-store.service'

Expand Down Expand Up @@ -96,5 +97,112 @@ describe(DataStoreService.name, () => {
})
})
})

const testThrowDataStoreException = async (params: {
store: unknown
expect: { message: string; status: HttpStatus }
}): Promise<void> => {
await withTempJsonFile(JSON.stringify(params.store), async (path) => {
const url = `file://${path}`

expect.assertions(3)

try {
await service.fetch({
entity: {
dataUrl: url,
signatureUrl: url,
keys: []
},
policy: {
dataUrl: url,
signatureUrl: url,
keys: []
}
})
} catch (error) {
expect(error).toBeInstanceOf(DataStoreException)
expect(error.message).toEqual(params.expect.message)
expect(error.status).toEqual(params.expect.status)
}
})
}

it('throws DataStoreException when entity schema is invalid', async () => {
await testThrowDataStoreException({
store: {
entity: {
data: ['invalid', 'schema'],
signature: 'test-signature'
},
policy: {
data: policyData.policy.data,
signature: 'test-signature'
}
},
expect: {
message: 'Invalid store schema',
status: HttpStatus.UNPROCESSABLE_ENTITY
}
})
})

it('throws DataStoreException when entity domain is invalid', async () => {
const duplicateUserGroups = [
{
id: '1'
},
{
id: '1'
}
]

await testThrowDataStoreException({
store: {
entity: {
data: {
userGroups: duplicateUserGroups,
addressBook: [],
credentials: [],
tokens: [],
userGroupMembers: [],
userWallets: [],
users: [],
walletGroupMembers: [],
walletGroups: [],
wallets: []
},
signature: 'test-signature'
},
policy: {
data: policyData.policy.data,
signature: 'test-signature'
}
},
expect: {
message: 'Invalid entity domain invariant',
status: HttpStatus.UNPROCESSABLE_ENTITY
}
})
})

it('throws DataStoreException when policy schema is invalid', async () => {
await testThrowDataStoreException({
store: {
policy: {
data: { invalid: 'schema' },
signature: 'test-signature'
},
entity: {
data: FIXTURE.ENTITIES,
signature: 'test-signature'
}
},
expect: {
message: 'Invalid store schema',
status: HttpStatus.UNPROCESSABLE_ENTITY
}
})
})
})
})
50 changes: 40 additions & 10 deletions apps/policy-engine/src/engine/core/service/data-store.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
DataStoreConfiguration,
EntityStore,
EntityUtil,
PolicyStore,
entityDataSchema,
entitySignatureSchema,
Expand All @@ -20,23 +21,52 @@ export class DataStoreService {
entity: EntityStore
policy: PolicyStore
}> {
const [entityData, entitySignature, policyData, policySignature] = await Promise.all([
this.fetchByUrl(store.entity.dataUrl, entityDataSchema),
this.fetchByUrl(store.entity.signatureUrl, entitySignatureSchema),
this.fetchByUrl(store.policy.dataUrl, policyDataSchema),
this.fetchByUrl(store.policy.signatureUrl, policySignatureSchema)
const [entityStore, policyStore] = await Promise.all([
this.fetchEntity(store.entity),
this.fetchPolicy(store.policy)
])

return {
entity: {
entity: entityStore,
policy: policyStore
}
}

async fetchEntity(store: DataStoreConfiguration): Promise<EntityStore> {
const [entityData, entitySignature] = await Promise.all([
this.fetchByUrl(store.dataUrl, entityDataSchema),
this.fetchByUrl(store.signatureUrl, entitySignatureSchema)
])

const validation = EntityUtil.validate(entityData.entity.data)

if (validation.success) {
return {
data: entityData.entity.data,
signature: entitySignature.entity.signature
},
policy: {
data: policyData.policy.data,
signature: policySignature.policy.signature
}
}

throw new DataStoreException({
message: 'Invalid entity domain invariant',
suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
context: {
url: store.dataUrl,
errors: validation.issues
}
})
}

async fetchPolicy(store: DataStoreConfiguration): Promise<PolicyStore> {
const [policyData, policySignature] = await Promise.all([
this.fetchByUrl(store.dataUrl, policyDataSchema),
this.fetchByUrl(store.signatureUrl, policySignatureSchema)
])

return {
data: policyData.policy.data,
signature: policySignature.policy.signature
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ADDRESS_BOOK, CREDENTIAL, TOKEN, USER, USER_GROUP, WALLET, WALLET_GROUP } from '../../../dev.fixture'
import { AccountClassification, Entities } from '../../../type/entity.type'
import { Entities } from '../../../type/entity.type'
import { validate } from '../../entity.util'

describe('validate', () => {
Expand Down Expand Up @@ -280,56 +280,4 @@ describe('validate', () => {
})
})
})

describe('id format', () => {
it('fails when address book account id is not an account id', () => {
const invalidAccountId = '16aba381-c54a-4f72-89bd-bd1e7c46ed29'
const result = validate({
...emptyEntities,
addressBook: [
{
id: invalidAccountId,
address: WALLET.Engineering.address,
chainId: 137,
classification: AccountClassification.WALLET
},
ADDRESS_BOOK[0]
]
})

expect(result).toEqual({
success: false,
issues: [
{
code: 'INVALID_UID_FORMAT',
message: `address book account id ${invalidAccountId} is not a valid account id`
}
]
})
})

it('fails when token id is not an asset id', () => {
const invalidAccountId = '16aba381-c54a-4f72-89bd-bd1e7c46ed29'
const result = validate({
...emptyEntities,
tokens: [
{
...TOKEN.usdc1,
id: invalidAccountId
},
TOKEN.usdc137
]
})

expect(result).toEqual({
success: false,
issues: [
{
code: 'INVALID_UID_FORMAT',
message: `token id ${invalidAccountId} is not a valid asset id`
}
]
})
})
})
})
27 changes: 1 addition & 26 deletions packages/policy-engine-shared/src/lib/util/entity.util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { countBy, flatten, indexBy, keys, map, pickBy } from 'lodash/fp'
import { Entities } from '../type/entity.type'
import { isAccountId, isAssetId } from './caip.util'

export type ValidationIssue = {
code: string
Expand Down Expand Up @@ -102,35 +101,11 @@ const validateUniqueIdDuplication: Validator = (entities: Entities): ValidationI
])
}

const validateAddressBookUniqueIdFormat: Validator = (entities: Entities): ValidationIssue[] => {
return entities.addressBook
.filter(({ id: uid }) => !isAccountId(uid))
.map(({ id: uid }) => {
return {
code: 'INVALID_UID_FORMAT',
message: `address book account id ${uid} is not a valid account id`
}
})
}

const validateTokenUniqueIdFormat: Validator = (entities: Entities): ValidationIssue[] => {
return entities.tokens
.filter(({ id: uid }) => !isAssetId(uid))
.map(({ id: uid }) => {
return {
code: 'INVALID_UID_FORMAT',
message: `token id ${uid} is not a valid asset id`
}
})
}

export const DEFAULT_VALIDATORS: Validator[] = [
validateUserGroupMemberIntegrity,
validateWalletGroupMemberIntegrity,
validateUserWalletIntegrity,
validateUniqueIdDuplication,
validateAddressBookUniqueIdFormat,
validateTokenUniqueIdFormat
validateUniqueIdDuplication
// TODO (@wcalderipe, 21/02/25): Missing domain invariants to be validate
// - fails when root user does not have a credential
// - fails when credential does not have a user
Expand Down

0 comments on commit fcfaf8b

Please sign in to comment.