Skip to content

Commit

Permalink
feat: ACLs endpoints for authorizing other wallets to deploy a name (#49
Browse files Browse the repository at this point in the history
)
  • Loading branch information
marianogoldman authored Feb 15, 2023
1 parent 5688686 commit d8ca8d6
Show file tree
Hide file tree
Showing 17 changed files with 708 additions and 87 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ RUN yarn build
# remove devDependencies, keep only used dependencies
RUN yarn install --prod --frozen-lockfile

# Make commit hash available to application
ARG COMMIT_HASH
RUN echo "COMMIT_HASH=$COMMIT_HASH" >> .env

Expand Down
39 changes: 27 additions & 12 deletions src/adapters/dcl-name-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ContractFactory, RequestManager } from 'eth-connect'
import { checkerAbi, checkerContracts, registrarContracts } from '@dcl/catalyst-contracts'

type NamesResponse = {
names: { name: string }[]
nfts: { name: string; owner: { id: string } }[]
}

export const createDclNameChecker = (
Expand All @@ -14,26 +14,41 @@ export const createDclNameChecker = (
const logger = components.logs.getLogger('check-permissions')
logger.info('Using TheGraph DclNameChecker')

const cache = new LRU<EthAddress, string[]>({
const cache = new LRU<string, string | undefined>({
max: 100,
ttl: 5 * 60 * 1000, // cache for 5 minutes
fetchMethod: async (ethAddress: EthAddress): Promise<string[]> => {
fetchMethod: async (worldName: string): Promise<string | undefined> => {
/*
DCL owners are case-sensitive, so when searching by dcl name in TheGraph we
need to do a case-insensitive search because the worldName provided as fetch key
may not be in the exact same case of the registered name. There are several methods
suffixed _nocase, but not one for equality, so this is a bit hackish, but it works.
*/
const result = await components.marketplaceSubGraph.query<NamesResponse>(
`
query FetchNames($ethAddress: String) {
names: nfts(where: { owner: $ethAddress, category: ens }, orderBy: name, first: 1000) {
query FetchOwnerForDclName($worldName: String) {
nfts(
where: {name_starts_with_nocase: $worldName, name_ends_with_nocase: $worldName, category: ens}
orderBy: name
first: 1000
) {
name
owner {
id
}
}
}`,
}`,
{
ethAddress: ethAddress.toLowerCase()
worldName: worldName.toLowerCase().replace('.dcl.eth', '')
}
)
logger.info(`Fetched owner of world ${worldName}: ${result.nfts.map(({ owner }) => owner.id.toLowerCase())}`)

const names = result.names.map(({ name }) => `${name.toLowerCase()}.dcl.eth`)
const owners = result.nfts
.filter((nft) => `${nft.name.toLowerCase()}.dcl.eth` === worldName.toLowerCase())
.map(({ owner }) => owner.id.toLowerCase())

logger.debug(`Fetched names for address ${ethAddress}: ${names}`)
return names
return owners.length > 0 ? owners[0] : undefined
}
})

Expand All @@ -42,8 +57,8 @@ export const createDclNameChecker = (
return false
}

const names = (await cache.fetch(ethAddress.toLowerCase()))!
return names.includes(worldName.toLowerCase())
const owner = await cache.fetch(worldName)
return !!owner && owner === ethAddress.toLowerCase()
}

return {
Expand Down
43 changes: 36 additions & 7 deletions src/adapters/validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { DeploymentToValidate, Validation, ValidationResult, Validator, ValidatorComponents } from '../types'
import {
AccessControlList,
DeploymentToValidate,
Validation,
ValidationResult,
Validator,
ValidatorComponents
} from '../types'
import { AuthChain, Entity, EthAddress, IPFSv2 } from '@dcl/schemas'
import { Authenticator } from '@dcl/crypto'
import { hashV1 } from '@dcl/hashing'
Expand Down Expand Up @@ -97,8 +104,8 @@ export const validateSignature: Validation = async (
return createValidationResult(result.message ? [result.message] : [])
}

export const validateDclName: Validation = async (
components: Pick<ValidatorComponents, 'namePermissionChecker'>,
export const validateDeploymentPermission: Validation = async (
components: Pick<ValidatorComponents, 'namePermissionChecker' | 'worldsManager'>,
deployment: DeploymentToValidate
): Promise<ValidationResult> => {
const sceneJson = JSON.parse(deployment.files.get(deployment.entity.id)!.toString())
Expand All @@ -107,9 +114,31 @@ export const validateDclName: Validation = async (

const hasPermission = await components.namePermissionChecker.checkPermission(signer, worldSpecifiedName)
if (!hasPermission) {
return createValidationResult([
`Deployment failed: Your wallet has no permission to publish this scene because it does not have permission to deploy under "${worldSpecifiedName}". Check scene.json to select a name you own.`
])
async function allowedByAcl(worldName: string, address: EthAddress): Promise<boolean> {
const worldMetadata = await components.worldsManager.getMetadataForWorld(worldName)
if (!worldMetadata || !worldMetadata.acl) {
// No acl -> no permission
return false
}

const acl = JSON.parse(worldMetadata.acl.slice(-1).pop()!.payload) as AccessControlList
const isAllowed = acl.allowed.some((allowedAddress) => allowedAddress.toLowerCase() === address.toLowerCase())
if (!isAllowed) {
// There is acl but requested address is not included in the allowed ones
return false
}

// The acl allows permissions, finally check that the signer of the acl still owns the world
const aclSigner = worldMetadata.acl[0].payload
return components.namePermissionChecker.checkPermission(aclSigner, worldName)
}

const allowed = await allowedByAcl(worldSpecifiedName, signer)
if (!allowed) {
return createValidationResult([
`Deployment failed: Your wallet has no permission to publish this scene because it does not have permission to deploy under "${worldSpecifiedName}". Check scene.json to select a name that either you own or you were given permission to deploy.`
])
}
}

return OK
Expand Down Expand Up @@ -242,7 +271,7 @@ const quickValidations: Validation[] = [
// validateSdkVersion TODO re-enable (and test) once SDK7 is ready
]

const slowValidations: Validation[] = [validateSize, validateDclName]
const slowValidations: Validation[] = [validateSize, validateDeploymentPermission]

const allValidations: Validation[] = [...quickValidations, ...slowValidations]

Expand Down
55 changes: 45 additions & 10 deletions src/adapters/worlds-manager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { AppComponents, IWorldsManager } from '../types'
import { AppComponents, IWorldsManager, WorldMetadata } from '../types'
import LRU from 'lru-cache'
import { streamToBuffer } from '@dcl/catalyst-storage/dist/content-item'
import { Entity } from '@dcl/schemas'
import { bufferToStream, streamToBuffer } from '@dcl/catalyst-storage/dist/content-item'
import { AuthChain, Entity } from '@dcl/schemas'
import { stringToUtf8Bytes } from 'eth-connect'

export async function createWorldsManagerComponent({
storage,
logs
}: Pick<AppComponents, 'storage' | 'logs'>): Promise<IWorldsManager> {
logs,
storage
}: Pick<AppComponents, 'logs' | 'storage'>): Promise<IWorldsManager> {
const logger = logs.getLogger('worlds-manager')
const WORLDS_KEY = 'worlds'
const cache = new LRU<string, string[]>({
Expand All @@ -25,6 +26,17 @@ export async function createWorldsManagerComponent({
}
}
})
const worldsCache = new LRU<string, WorldMetadata>({
max: 100,
ttl: 10 * 60 * 1000, // cache for 10 minutes
fetchMethod: async (worldName, staleValue): Promise<WorldMetadata | undefined> => {
const content = await storage.retrieve(`name-${worldName.toLowerCase()}`)
if (!content) {
return staleValue
}
return JSON.parse((await streamToBuffer(await content.asStream())).toString())
}
})

async function getDeployedWorldsNames(): Promise<string[]> {
return (await cache.fetch(WORLDS_KEY))!
Expand Down Expand Up @@ -55,22 +67,45 @@ export async function createWorldsManagerComponent({
}
}

async function getMetadataForWorld(worldName: string): Promise<WorldMetadata | undefined> {
return await worldsCache.fetch(worldName)
}

async function getEntityIdForWorld(worldName: string): Promise<string | undefined> {
const content = await storage.retrieve(`name-${worldName.toLowerCase()}`)
const content = await worldsCache.fetch(worldName)
if (!content) {
return undefined
}

const buffer = await streamToBuffer(await content?.asStream())
const { entityId } = JSON.parse(buffer.toString())
const { entityId } = content

return entityId
}

async function storeAcl(worldName: string, acl: AuthChain): Promise<void> {
const content = await worldsCache.fetch(worldName)
const { entityId } = content!

await storage.storeStream(
`name-${worldName}`,
bufferToStream(
stringToUtf8Bytes(
JSON.stringify({
entityId,
acl: acl
})
)
)
)
worldsCache.delete(worldName)
}

return {
getDeployedWorldsNames,
getDeployedWorldsCount,
getMetadataForWorld,
getEntityIdForWorld,
getEntityForWorld
getEntityForWorld,
storeAcl
}
}
7 changes: 4 additions & 3 deletions src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,17 @@ export async function initComponents(): Promise<AppComponents> {

const limitsManager = await createLimitsManagerComponent({ config, fetch, logs })

const worldsManager = await createWorldsManagerComponent({ logs, storage })

const validator = createValidator({
config,
namePermissionChecker,
ethereumProvider,
limitsManager,
storage
storage,
worldsManager
})

const worldsManager = await createWorldsManagerComponent({ logs, storage })

return {
commsAdapter,
config,
Expand Down
113 changes: 113 additions & 0 deletions src/controllers/handlers/aclHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { IHttpServerComponent } from '@well-known-components/interfaces'
import { AccessControlList, HandlerContextWithPath } from '../../types'
import { AuthChain, EthAddress } from '@dcl/schemas'

export async function getAclHandler(
ctx: HandlerContextWithPath<'namePermissionChecker' | 'worldsManager', '/acl/:world_name'>
): Promise<IHttpServerComponent.IResponse> {
const { namePermissionChecker, worldsManager } = ctx.components

const worldName = ctx.params.world_name

const worldMetadata = await worldsManager.getMetadataForWorld(worldName)
if (!worldMetadata) {
return {
status: 404,
body: `World "${worldName}" not deployed in this server.`
}
}

if (!worldMetadata.acl) {
return {
status: 200,
body: {
resource: worldName,
allowed: []
} as AccessControlList
}
}

// Check that the ACL was signed by the wallet that currently owns the world, or else return empty
const ethAddress = worldMetadata.acl[0].payload
const permission = await namePermissionChecker.checkPermission(ethAddress, worldName)
const acl: AccessControlList = !permission
? {
resource: worldName,
allowed: []
}
: // Get the last element of the auth chain. The payload must contain the AccessControlList
JSON.parse(worldMetadata.acl.slice(-1).pop()!.payload)

return {
status: 200,
body: acl
}
}

export async function postAclHandler(
ctx: HandlerContextWithPath<'namePermissionChecker' | 'worldsManager', '/acl/:world_name'>
): Promise<IHttpServerComponent.IResponse> {
const { namePermissionChecker, worldsManager } = ctx.components

const worldName = ctx.params.world_name

const worldMetadata = await worldsManager.getMetadataForWorld(worldName)
if (!worldMetadata) {
return {
status: 404,
body: {
message: `World "${worldName}" not deployed in this server.`
}
}
}

const authChain = (await ctx.request.json()) as AuthChain
if (!AuthChain.validate(authChain)) {
return {
status: 400,
body: {
message: `Invalid payload received. Need to be a valid AuthChain.`
}
}
}

const permission = await namePermissionChecker.checkPermission(authChain[0].payload, worldName)
if (!permission) {
return {
status: 403,
body: {
message: `Your wallet does not own "${worldName}", you can not set access control lists for it.`
}
}
}

const acl = JSON.parse(authChain[authChain.length - 1].payload)
if (acl.resource !== worldName) {
return {
status: 400,
body: {
message: `Provided acl is for world "${acl.resource}" but you are trying to set acl for world ${worldName}.`
}
}
}

if (
!acl.allowed ||
!Array.isArray(acl.allowed) ||
!acl.allowed.every((address: string) => EthAddress.validate(address))
) {
return {
status: 400,
body: {
message: `Provided acl is invalid. allowed is missing or not an array of addresses.`
}
}
}

await worldsManager.storeAcl(worldName, authChain)

return {
status: 200,
body: acl
}
}
Loading

0 comments on commit d8ca8d6

Please sign in to comment.