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 17 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
94 changes: 61 additions & 33 deletions locksmith/src/controllers/v2/checkoutController.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { RequestHandler } from 'express'
import { PaywallConfig } from '@unlock-protocol/core'
import { CheckoutConfig } from '../../models'
import {
getCheckoutConfigById,
saveCheckoutConfig,
deleteCheckoutConfigOperation,
getCheckoutConfigsByUserOperation,
} from '../../operations/checkoutConfigOperations'
import { PaywallConfig } from '@unlock-protocol/core'

/**
* 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
Expand All @@ -18,45 +26,52 @@ export const createOrUpdateCheckoutConfig: RequestHandler = async (
})
}
const checkoutConfig = await PaywallConfig.strip().parseAsync(config)
const createdConfig = await saveCheckoutConfig({
const storedConfig = await saveCheckoutConfig({
id,
name,
config: checkoutConfig,
createdBy: request.user!.walletAddress,
user: 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(),
id: storedConfig.id,
by: storedConfig.createdBy,
name: storedConfig.name,
config: storedConfig.config,
updatedAt: storedConfig.updatedAt.toISOString(),
createdAt: storedConfig.createdAt.toISOString(),
})
}

/**
* 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 +86,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
139 changes: 134 additions & 5 deletions locksmith/src/operations/checkoutConfigOperations.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,116 @@
import { randomUUID } from 'crypto'
import { CheckoutConfig } from '../models'
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
}
/**
* 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
* @throws Error if user is not authorized to update the config
*/
// Creates or updates a checkout config
export const saveCheckoutConfig = async ({
id,
name,
createdBy,
user,
config,
}: SaveCheckoutConfigArgs) => {
const generatedId = randomUUID()

// check if user is authorized
if (id) {
const existingConfig = await CheckoutConfig.findOne({
where: { id },
})
if (existingConfig) {
const authorized = await isUserAuthorized(user, existingConfig)
if (!authorized) {
throw new Error('User not authorized to update this configuration')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this throws then we need to handle it specifically at the controller level to return a 403

}
}
}

// Forcing the referrer to exist and be set to the creator of the config
if (!config.referrer) {
config.referrer = createdBy
config.referrer = user
}

const [createdConfig] = await CheckoutConfig.upsert(
{
name,
id: id || generatedId,
config,
createdBy,
createdBy: user,
0xTxbi marked this conversation as resolved.
Show resolved Hide resolved
},
{
conflictFields: ['id', 'createdBy'],
Expand All @@ -36,7 +119,11 @@ export const saveCheckoutConfig = async ({
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 +142,45 @@ export const getCheckoutConfigById = async (id: string) => {
}
return null
}

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

/**
* 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