feat: make auth based off user roles and tenant access, and tenant roles
This commit is contained in:
parent
eb2737fd5e
commit
723dda191b
10
src/access/isSuperAdmin.ts
Normal file
10
src/access/isSuperAdmin.ts
Normal 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'))
|
||||
}
|
||||
39
src/collections/Tenants/access/byTenant.ts
Normal file
39
src/collections/Tenants/access/byTenant.ts
Normal 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) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
19
src/collections/Tenants/access/updateAndDelete.ts
Normal file
19
src/collections/Tenants/access/updateAndDelete.ts
Normal 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'),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
24
src/collections/Users/access/create.ts
Normal file
24
src/collections/Users/access/create.ts
Normal 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
|
||||
}
|
||||
5
src/collections/Users/access/isAccessingSelf.ts
Normal file
5
src/collections/Users/access/isAccessingSelf.ts
Normal 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
|
||||
}
|
||||
56
src/collections/Users/access/read.ts
Normal file
56
src/collections/Users/access/read.ts
Normal 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
|
||||
}
|
||||
31
src/collections/Users/access/updateAndDelete.ts
Normal file
31
src/collections/Users/access/updateAndDelete.ts
Normal 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'),
|
||||
},
|
||||
}
|
||||
}
|
||||
130
src/collections/Users/endpoints/externalUsersLogin.ts
Normal file
130
src/collections/Users/endpoints/externalUsersLogin.ts
Normal 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',
|
||||
}
|
||||
76
src/collections/Users/hooks/ensureUniqueUsername.ts
Normal file
76
src/collections/Users/hooks/ensureUniqueUsername.ts
Normal 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
|
||||
}
|
||||
39
src/collections/Users/hooks/setCookieBasedOnDomain.ts
Normal file
39
src/collections/Users/hooks/setCookieBasedOnDomain.ts
Normal 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
|
||||
}
|
||||
@ -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],
|
||||
},
|
||||
}
|
||||
|
||||
10
src/utilities/extractID.ts
Normal file
10
src/utilities/extractID.ts
Normal 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
|
||||
}
|
||||
9
src/utilities/getCollectionIDType.ts
Normal file
9
src/utilities/getCollectionIDType.ts
Normal 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
|
||||
}
|
||||
31
src/utilities/getUserTenantIds.ts
Normal file
31
src/utilities/getUserTenantIds.ts
Normal 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
|
||||
}, []) || []
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user