feat: make auth based off user roles and tenant access, and tenant roles

This commit is contained in:
Yehoshua Sandler 2025-05-16 19:01:07 -05:00
parent eb2737fd5e
commit 723dda191b
15 changed files with 508 additions and 21 deletions

View File

@ -0,0 +1,10 @@
import type { Access } from 'payload'
import { User } from '../payload-types'
export const isSuperAdminAccess: Access = ({ req }): boolean => {
return isSuperAdmin(req.user)
}
export const isSuperAdmin = (user: User | null): boolean => {
return Boolean(user?.roles?.includes('super-admin'))
}

View File

@ -0,0 +1,39 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
export const filterByTenantRead: Access = (args) => {
// Allow public tenants to be read by anyone
if (!args.req.user) {
return {
allowPublicRead: {
equals: true,
},
}
}
return true
}
export const canMutateTenant: Access = ({ req }) => {
if (!req.user) {
return false
}
if (isSuperAdmin(req.user)) {
return true
}
return {
id: {
in:
req.user?.tenants
?.map(({ roles, tenant }) =>
roles?.includes('tenant-admin')
? tenant && (typeof tenant === 'string' ? tenant : tenant.id)
: null,
)
.filter(Boolean) || [],
},
}
}

View File

@ -0,0 +1,19 @@
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
import { Access } from 'payload'
export const updateAndDeleteAccess: Access = ({ req }) => {
if (!req.user) {
return false
}
if (isSuperAdmin(req.user)) {
return true
}
return {
id: {
in: getUserTenantIDs(req.user, 'tenant-admin'),
},
}
}

View File

@ -1,15 +1,15 @@
import type { CollectionConfig } from 'payload'
//import { updateAndDeleteAccess } from './access/updateAndDelete'
import { isSuperAdmin } from '@/access/admin'
import { isSuperAdminAccess } from '@/access/isSuperAdmin'
import { updateAndDeleteAccess } from './access/updateAndDelete'
export const Tenants: CollectionConfig = {
slug: 'tenants',
access: {
create: isSuperAdmin,
delete: isSuperAdmin, // change these to the example soon!
read: ({ req }) => true,
update: isSuperAdmin,
create: isSuperAdminAccess,
delete: updateAndDeleteAccess,
read: ({ req }) => Boolean(req.user),
update: updateAndDeleteAccess,
},
admin: {
useAsTitle: 'name',

View File

@ -0,0 +1,24 @@
import type { Access } from 'payload'
import type { User } from '../../../payload-types'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
export const createAccess: Access<User> = ({ req }) => {
if (!req.user) {
return false
}
if (isSuperAdmin(req.user)) {
return true
}
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
if (adminTenantAccessIDs.length) {
return true
}
return false
}

View File

@ -0,0 +1,5 @@
import { User } from '@/payload-types'
export const isAccessingSelf = ({ id, user }: { user?: User; id?: string | number }): boolean => {
return user ? Boolean(user.id === id) : false
}

View File

@ -0,0 +1,56 @@
import type { User } from '@/payload-types'
import type { Access, Where } from 'payload'
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { isAccessingSelf } from './isAccessingSelf'
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
export const readAccess: Access<User> = ({ req, id }) => {
if (!req?.user) {
return false
}
if (isAccessingSelf({ id, user: req.user })) {
return true
}
const superAdmin = isSuperAdmin(req.user)
const selectedTenant = getTenantFromCookie(
req.headers,
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
)
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
if (selectedTenant) {
// If it's a super admin, or they have access to the tenant ID set in cookie
const hasTenantAccess = adminTenantAccessIDs.some((id) => id === selectedTenant)
if (superAdmin || hasTenantAccess) {
return {
'tenants.tenant': {
equals: selectedTenant,
},
}
}
}
if (superAdmin) {
return true
}
return {
or: [
{
id: {
equals: req.user.id,
},
},
{
'tenants.tenant': {
in: adminTenantAccessIDs,
},
},
],
} as Where
}

View File

@ -0,0 +1,31 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { isAccessingSelf } from './isAccessingSelf'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
export const updateAndDeleteAccess: Access = ({ req, id }) => {
const { user } = req
if (!user) {
return false
}
if (isSuperAdmin(user) || isAccessingSelf({ user, id })) {
return true
}
/**
* Constrains update and delete access to users that belong
* to the same tenant as the tenant-admin making the request
*
* You may want to take this a step further with a beforeChange
* hook to ensure that the a tenant-admin can only remove users
* from their own tenant in the tenants array.
*/
return {
'tenants.tenant': {
in: getUserTenantIDs(user, 'tenant-admin'),
},
}
}

View File

@ -0,0 +1,130 @@
import type { Collection, Endpoint } from 'payload'
import { headersWithCors } from '@payloadcms/next/utilities'
import { APIError, generatePayloadCookie } from 'payload'
// A custom endpoint that can be reached by POST request
// at: /api/users/external-users/login
export const externalUsersLogin: Endpoint = {
handler: async (req) => {
let data: { [key: string]: string } = {}
try {
if (typeof req.json === 'function') {
data = await req.json()
}
} catch (error) {
// swallow error, data is already empty object
}
const { password, tenantSlug, tenantDomain, username } = data
if (!username || !password) {
throw new APIError('Username and Password are required for login.', 400, null, true)
}
const fullTenant = (
await req.payload.find({
collection: 'tenants',
where: tenantDomain
? {
domain: {
equals: tenantDomain,
},
}
: {
slug: {
equals: tenantSlug,
},
},
})
).docs[0]
const foundUser = await req.payload.find({
collection: 'users',
where: {
or: [
{
and: [
{
email: {
equals: username,
},
},
{
'tenants.tenant': {
equals: fullTenant?.id,
},
},
],
},
{
and: [
{
username: {
equals: username,
},
},
{
'tenants.tenant': {
equals: fullTenant?.id,
},
},
],
},
],
},
})
if (foundUser?.totalDocs > 0) {
try {
const loginAttempt = await req.payload.login({
collection: 'users',
data: {
email: foundUser?.docs[0]?.email || '',
password,
},
req,
})
if (loginAttempt?.token) {
const collection: Collection = (req.payload.collections as { [key: string]: Collection })[
'users'
]!
const cookie = generatePayloadCookie({
collectionAuthConfig: collection.config.auth,
cookiePrefix: req.payload.config.cookiePrefix,
token: loginAttempt.token,
})
return Response.json(loginAttempt, {
headers: headersWithCors({
headers: new Headers({
'Set-Cookie': cookie,
}),
req,
}),
status: 200,
})
}
throw new APIError(
'Unable to login with the provided username and password.',
400,
null,
true,
)
} catch (e) {
throw new APIError(
'Unable to login with the provided username and password.',
400,
null,
true,
)
}
}
throw new APIError('Unable to login with the provided username and password.', 400, null, true)
},
method: 'post',
path: '/external-users/login',
}

View File

@ -0,0 +1,76 @@
import type { FieldHook, Where } from 'payload'
import { ValidationError } from 'payload'
import { extractID } from '@/utilities/extractID'
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation
if (originalDoc.username === value) {
return value
}
const constraints: Where[] = [
{
username: {
equals: value,
},
},
]
const selectedTenant = getTenantFromCookie(
req.headers,
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
)
if (selectedTenant) {
constraints.push({
'tenants.tenant': {
equals: selectedTenant,
},
})
}
const findDuplicateUsers = await req.payload.find({
collection: 'users',
where: {
and: constraints,
},
})
if (findDuplicateUsers.docs.length > 0 && req.user) {
const tenantIDs = getUserTenantIDs(req.user)
// if the user is an admin or has access to more than 1 tenant
// provide a more specific error message
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
const attemptedTenantChange = await req.payload.findByID({
// @ts-ignore - selectedTenant will match DB ID type
id: selectedTenant,
collection: 'tenants',
})
throw new ValidationError({
errors: [
{
message: `The "${attemptedTenantChange.name}" tenant already has a user with the username "${value}". Usernames must be unique per tenant.`,
path: 'username',
},
],
})
}
throw new ValidationError({
errors: [
{
message: `A user with the username ${value} already exists. Usernames must be unique per tenant.`,
path: 'username',
},
],
})
}
return value
}

View File

@ -0,0 +1,39 @@
import type { CollectionAfterLoginHook } from 'payload'
import { mergeHeaders, generateCookie, getCookieExpiration } from 'payload'
export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, user }) => {
const relatedOrg = await req.payload.find({
collection: 'tenants',
depth: 0,
limit: 1,
where: {
domain: {
equals: req.headers.get('host'),
},
},
})
// If a matching tenant is found, set the 'payload-tenant' cookie
if (relatedOrg && relatedOrg.docs.length > 0) {
const tenantCookie = generateCookie({
name: 'payload-tenant',
expires: getCookieExpiration({ seconds: 7200 }),
path: '/',
returnCookieAsObject: false,
value: String(relatedOrg.docs[0].id),
})
// Merge existing responseHeaders with the new Set-Cookie header
const newHeaders = new Headers({
'Set-Cookie': tenantCookie as string,
})
// Ensure you merge existing response headers if they already exist
req.responseHeaders = req.responseHeaders
? mergeHeaders(req.responseHeaders, newHeaders)
: newHeaders
}
return user
}

View File

@ -1,7 +1,12 @@
import type { CollectionConfig } from 'payload'
import { authenticated } from '../../access/authenticated'
import { isSuperAdmin } from '@/access/admin'
import { createAccess } from './access/create'
import { readAccess } from './access/read'
import { updateAndDeleteAccess } from './access/updateAndDelete'
import { externalUsersLogin } from './endpoints/externalUsersLogin'
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain'
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
export enum UserAccessLevel {
@ -35,17 +40,17 @@ const defaultTenantArrayField = tenantsArrayField({
export const Users: CollectionConfig = {
slug: 'users',
access: {
admin: authenticated,
create: authenticated,
delete: authenticated,
read: authenticated,
update: authenticated,
create: createAccess,
delete: updateAndDeleteAccess,
read: readAccess,
update: updateAndDeleteAccess,
},
admin: {
defaultColumns: ['name', 'email'],
useAsTitle: 'username',
},
auth: true,
endpoints: [externalUsersLogin],
fields: [
{
admin: {
@ -56,18 +61,18 @@ export const Users: CollectionConfig = {
defaultValue: ['guest'],
hasMany: true,
options: ['full-access', 'super-admin', 'user', 'guest'],
// access: {
// update: ({ req }) => {
// return isSuperAdmin({req})
// },
// },
access: {
update: ({ req }) => {
return isSuperAdmin(req.user)
},
},
},
{
name: 'username',
type: 'text',
// hooks: {
// beforeValidate: [ensureUniqueUsername],
// },
hooks: {
beforeValidate: [ensureUniqueUsername],
},
index: true,
},
{
@ -85,4 +90,7 @@ export const Users: CollectionConfig = {
},
],
timestamps: true,
hooks: {
afterLogin: [setCookieBasedOnDomain],
},
}

View File

@ -0,0 +1,10 @@
import { Config } from '@/payload-types'
import type { CollectionSlug } from 'payload'
export const extractID = <T extends Config['collections'][CollectionSlug]>(
objectOrID: T | T['id'],
): T['id'] => {
if (objectOrID && typeof objectOrID === 'object') return objectOrID.id
return objectOrID
}

View File

@ -0,0 +1,9 @@
import type { CollectionSlug, Payload } from 'payload'
type Args = {
collectionSlug: CollectionSlug
payload: Payload
}
export const getCollectionIDType = ({ collectionSlug, payload }: Args): 'number' | 'text' => {
return payload.collections[collectionSlug]?.customIDType ?? payload.db.defaultIDType
}

View File

@ -0,0 +1,31 @@
import type { Tenant, User } from '@/payload-types'
import { extractID } from './extractID'
/**
* Returns array of all tenant IDs assigned to a user
*
* @param user - User object with tenants field
* @param role - Optional role to filter by
*/
export const getUserTenantIDs = (
user: null | User,
role?: NonNullable<User['tenants']>[number]['roles'][number],
): Tenant['id'][] => {
if (!user) {
return []
}
return (
user?.tenants?.reduce<Tenant['id'][]>((acc, { roles, tenant }) => {
if (role && !roles.includes(role)) {
return acc
}
if (tenant) {
acc.push(extractID(tenant))
}
return acc
}, []) || []
)
}