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

feature(locksmith): Update access control for checkoutConfigs #14833

Merged
merged 24 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
122 changes: 83 additions & 39 deletions locksmith/src/controllers/v2/checkoutController.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,93 @@
import { RequestHandler } from 'express'
import { PaywallConfig } from '@unlock-protocol/core'
import { CheckoutConfig } from '../../models'
import {
getCheckoutConfigById,
saveCheckoutConfig,
deleteCheckoutConfigOperation,
getCheckoutConfigsByUserOperation,
} from '../../operations/checkoutConfigOperations'

/**
* Create or update a checkout configuration.
*
* This endpoint handles both creation of new configs and updates to existing ones.
* It performs authorization checks for updates and sanitizes input before persistence.
*
*/
export const createOrUpdateCheckoutConfig: RequestHandler = async (
request,
response
) => {
const id: string | undefined = request.params.id
const { config, name } = request.body

if (!(config && name)) {
return response.status(400).send({
message: 'Missing config or name',
})
}
const checkoutConfig = await PaywallConfig.strip().parseAsync(config)
const createdConfig = await saveCheckoutConfig({
id,
name,
config: checkoutConfig,
createdBy: request.user!.walletAddress,
})
return response.status(200).send({
id: createdConfig.id,
by: createdConfig.createdBy,
name: createdConfig.name,
config: createdConfig.config,
updatedAt: createdConfig.updatedAt.toISOString(),
createdAt: createdConfig.createdAt.toISOString(),
})

try {
const createdConfig = await saveCheckoutConfig({
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
id,
name,
config,
user: request.user!.walletAddress,
})

if (!createdConfig) {
return response.status(403).send({
message:
'Unauthorized: You do not have permission to create or update this configuration.',
})
}

return response.status(200).send({
id: createdConfig.id,
by: createdConfig.createdBy,
name: createdConfig.name,
config: createdConfig.config,
updatedAt: createdConfig.updatedAt.toISOString(),
createdAt: createdConfig.createdAt.toISOString(),
})
} catch (error) {
console.error('Error creating/updating checkout config:', error)
return response.status(500).send({
message: 'An error occurred while processing your request.',
})
}
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Retrieve a specific checkout configuration.
*
* This endpoint fetches the requested config without performing an authorization check.
*/
export const getCheckoutConfig: RequestHandler = async (request, response) => {
const id = request.params.id

const checkoutConfig = await getCheckoutConfigById(id)
const statusCode = checkoutConfig ? 200 : 404
const json = checkoutConfig
? checkoutConfig
: {
message: 'No config found',
}
return response.status(statusCode).send(json)
if (!checkoutConfig) {
return response.status(404).send({
message: 'No config found',
})
}

return response.status(200).send(checkoutConfig)
}

/**
* Retrieve all checkout configurations for a user.
*
* This endpoint returns all configurations that a user is authorized to manage.
* It's useful for populating user dashboards or configuration lists.
*/
export const getCheckoutConfigsByUser: RequestHandler = async (
request,
response
) => {
const userAddress = request.user!.walletAddress
const checkoutConfigs = await CheckoutConfig.findAll({
where: {
createdBy: userAddress,
},
order: [['updatedAt', 'DESC']],
})
const checkoutConfigs = await getCheckoutConfigsByUserOperation(userAddress)
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved

return response.status(200).send({
results: checkoutConfigs.map((config) => {
return {
Expand All @@ -71,24 +102,37 @@ export const getCheckoutConfigsByUser: RequestHandler = async (
})
}

/**
* Delete a checkout configuration.
*
* This endpoint performs an authorization check before attempting to delete the config.
* It ensures that only authorized users can delete a given configuration.
*
* TODO: Consider implementing soft delete for audit trail purposes.
*/
export const deleteCheckoutConfig: RequestHandler = async (
request,
response
) => {
const id = request.params.id
const userAddress = request.user!.walletAddress
const checkoutConfig = await CheckoutConfig.findOne({
where: {
id,
createdBy: userAddress,
},
})
if (!checkoutConfig) {

const existingConfig = await getCheckoutConfigById(id)

if (!existingConfig) {
return response.status(404).send({
message: 'Not found',
message: 'Config not found.',
})
}
await checkoutConfig.destroy()

const deleted = await deleteCheckoutConfigOperation(userAddress, id)
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved

if (!deleted) {
return response.status(403).send({
message: 'You do not have permission to delete this configuration.',
})
}

return response.status(200).send({
message: 'Config deleted',
})
Expand Down
157 changes: 144 additions & 13 deletions locksmith/src/operations/checkoutConfigOperations.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,132 @@
import { randomUUID } from 'crypto'
import { CheckoutConfig } from '../models'
import { PaywallConfig } from '@unlock-protocol/core'
import { Web3Service } from '@unlock-protocol/unlock-js'
import networks from '@unlock-protocol/networks'

interface SaveCheckoutConfigArgs {
id?: string
name: string
createdBy: string
user: string
config: any // TODO: TYPE
}

// Creates or updates a checkout config
/**
* Extracts lock addresses and network information from the config
* @param config - The checkout configuration object
* @returns An array of objects containing lock address and network
*/
const extractLockInfo = (
config: any
): { address: string; network: number }[] => {
const defaultNetwork = config.network
return Object.entries(config.locks).map(
([address, lockConfig]: [string, any]) => ({
address,
network: lockConfig.network || defaultNetwork,
})
)
}

/**
* Checks if a user is a manager for a given lock
* @param lockAddress - The address of the lock
* @param userAddress - The address of the user
* @param network - The network ID
* @returns A boolean indicating whether the user is a lock manager
*/
const isLockManager = async (
lockAddress: string,
userAddress: string,
network: number
): Promise<boolean> => {
const web3Service = new Web3Service(networks)
try {
return await web3Service.isLockManager(lockAddress, userAddress, network)
} catch (error) {
console.error(`Error checking lock manager status: ${error}`)
return false
}
}

/**
* Determines if a user is authorized to manage a checkout config
* @param userAddress - The address of the user
* @param checkoutConfig - The checkout configuration object
* @returns A boolean indicating whether the user is authorized
*/
const isUserAuthorized = async (
userAddress: string,
checkoutConfig: CheckoutConfig
): Promise<boolean> => {
// first check if user is the creator
if (checkoutConfig.createdBy === userAddress) {
return true
}

const lockInfo = extractLockInfo(checkoutConfig.config)
for (const { address, network } of lockInfo) {
if (await isLockManager(address, userAddress, network)) {
return true
}
}

return false
}

/**
* Creates or updates a checkout configuration
* @param args - The SaveCheckoutConfigArgs object
* @returns The created or updated checkout configuration or null if not authorized
*/
export const saveCheckoutConfig = async ({
id,
name,
createdBy,
user,
config,
}: SaveCheckoutConfigArgs) => {
const generatedId = randomUUID()
}: SaveCheckoutConfigArgs): Promise<CheckoutConfig | null> => {
const generatedId = id || randomUUID()
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved

// Forcing the referrer to exist and be set to the creator of the config
if (!config.referrer) {
config.referrer = createdBy
const checkoutConfigData = await PaywallConfig.strip().parseAsync(config)

// Ensure referrer is set to the user
if (!checkoutConfigData.referrer) {
checkoutConfigData.referrer = user
}

// Check if the user is authorized to save/update the config
if (id) {
const existingConfig = await CheckoutConfig.findOne({ where: { id } })
if (existingConfig && !(await isUserAuthorized(user, existingConfig))) {
return null
}
}

const [createdConfig] = await CheckoutConfig.upsert(
{
id: generatedId,
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
name,
id: id || generatedId,
config,
createdBy,
config: checkoutConfigData,
createdBy: user,
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
},
{
conflictFields: ['id', 'createdBy'],
conflictFields: ['id'],
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
}
)

// Ensure createdConfig is not null before returning
if (!createdConfig) {
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
throw new Error('Failed to create or update checkout config')
}

return createdConfig
}

// Returns a checkout config by id
/**
* Retrieves a checkout configuration by its ID
* @param id - The ID of the checkout configuration
* @returns The checkout configuration object or null if not found
*/
export const getCheckoutConfigById = async (id: string) => {
const checkoutConfig = await CheckoutConfig.findOne({
where: {
Expand All @@ -55,3 +145,44 @@ export const getCheckoutConfigById = async (id: string) => {
}
return null
}

export const getCheckoutConfigsByUserOperation = async (
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
userAddress: string
) => {
return await CheckoutConfig.findAll({
where: {
createdBy: userAddress,
},
order: [['updatedAt', 'DESC']],
})
}

/**
* Deletes a checkout configuration if the user is authorized
* @param userAddress - The address of the user
* @param configId - The ID of the checkout configuration
* @returns A boolean indicating whether the deletion was successful
*/
export const deleteCheckoutConfigOperation = async (
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
userAddress: string,
configId: string
): Promise<boolean> => {
const checkoutConfig = await CheckoutConfig.findOne({
where: {
id: configId,
},
})

if (!checkoutConfig) {
return false
}

const authorized = await isUserAuthorized(userAddress, checkoutConfig)

if (!authorized) {
return false
}

await checkoutConfig.destroy()
return true
}
2 changes: 1 addition & 1 deletion locksmith/src/routes/v2/checkoutConfigs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { authenticatedMiddleware } from '../../utils/middlewares/auth'
import {
getCheckoutConfig,
createOrUpdateCheckoutConfig,
getCheckoutConfigsByUser,
deleteCheckoutConfig,
getCheckoutConfigsByUser,
} from '../../controllers/v2/checkoutController'
const router = express.Router({ mergeParams: true })

Expand Down
Loading