I solved this use case recently, by duplicating the custom claims as "roles" array field into the according firestore 'users/{uid}/private-user/{data}' documents. In my scenario I had to distinguish between two roles ("admin" and "superadmin"). The documents of the firestore 'users/' collection are public, and the documents of the 'users/{uid}/private-user/' collection are only accessible from the client side by the owning user and "superadmin" users, or via the firestore Admin SDK (server side) also only as "superadmin" user.
Additionally, I only wanted to allow "superadmin" users to add or remove "superadmin" or "admin" roles/claims; or to get a list of "superadmin" or "admin" users.
Data duplication is quite common in the NoSQL world, and is NOT considered as a bad practice.
Here is my code (Node.js/TypeScript)
First, the firebase cloud function implementation (requires Admin SDK) to add a custom user claim/role.
Note, that the "superadmin" validation line
await validateUserClaim(functionName, context, "superadmin")
must be removed until at least one "superadmin" has been created that can be used later on to add or remove additional roles/claims to users!
const functionName = "add-admin-user"
export default async (
payload: string,
context: CallableContext,
): Promise<void> => {
try {
validateAuthentication(functionName, context)
validateEmailVerified(functionName, context)
await validateUserClaim(functionName, context, "superadmin")
const request = parseRequestPayload<AddAdminUserRoleRequest>(
functionName,
payload,
)
// Note, to remove a custom claim just use "{ [request.roleName]: null }"
// as second input parameter.
await admin
.auth()
.setCustomUserClaims(request.uid, { [request.roleName]: true })
const userDoc = await db
.collection(`users/${request.uid}/private-user`)
.doc("data")
.get()
const roles = userDoc.data()?.roles ?? []
if (roles.indexOf(request.roleName) === -1) {
roles.push(request.roleName)
db.collection(`users/${request.uid}/private-user`)
.doc("data")
.set({ roles }, { merge: true })
}
} catch (e) {
throw logAndReturnHttpsError(
"internal",
`Firestore ${functionName} not executed. Failed to add 'admin' or ` +
`'superadmin' claim to user. (${(<Error>e)?.message})`,
`${functionName}/internal`,
e,
)
}
}
Second, the firebase cloud function implementation (requires Admin SDK) that returns a list of "superadmin" or "admin" users.
const functionName = "get-admin-users"
export default async (
payload: string,
context: CallableContext,
): Promise<GetAdminUsersResponse> => {
try {
validateAuthentication(functionName, context)
validateEmailVerified(functionName, context)
await validateUserClaim(functionName, context, "superadmin")
const request = parseRequestPayload<GetAdminUsersRequest>(
functionName,
payload,
)
const adminUserDocs = await db
.collectionGroup("private-user")
.where("roles", "array-contains", request.roleName)
.get()
const admins = adminUserDocs.docs.map((doc) => {
return {
uid: doc.data().uid,
username: doc.data().username,
email: doc.data().email,
roleName: request.roleName,
}
})
return { admins }
} catch (e) {
throw logAndReturnHttpsError(
"internal",
`Firestore ${functionName} not executed. Failed to query admin users. (${
(<Error>e)?.message
})`,
`${functionName}/internal`,
e,
)
}
}
And third, the validation helper functions (require the Admin SDK).
export type AdminRoles = "admin" | "superadmin"
export const validateAuthentication = (
functionName: string,
context: CallableContext,
): void => {
if (!context.auth || !context.auth?.uid) {
throw logAndReturnHttpsError(
"unauthenticated",
`Firestore ${functionName} not executed. User not authenticated.`,
`${functionName}/unauthenticated`,
)
}
}
export const validateUserClaim = async (
functionName: string,
context: CallableContext,
roleName: AdminRoles,
): Promise<void> => {
if (context.auth?.uid) {
const hasRole = await admin
.auth()
.getUser(context.auth?.uid)
.then((userRecord) => {
return !!userRecord.customClaims?.[roleName]
})
if (hasRole) {
return
}
}
throw logAndReturnHttpsError(
"unauthenticated",
`Firestore ${functionName} not executed. User not authenticated as ` +
`'${roleName}'. `,
`${functionName}/unauthenticated`,
)
}
export const validateEmailVerified = async (
functionName: string,
context: CallableContext,
): Promise<void> => {
if (context.auth?.uid) {
const userRecord = await auth.getUser(context.auth?.uid)
if (!userRecord.emailVerified) {
throw logAndReturnHttpsError(
"unauthenticated",
`Firestore ${functionName} not executed. Email is not verified.`,
`${functionName}/email-not-verified`,
)
}
}
}
Finally, custom claims can be added or removed only on the server side as the according "setCustomUserClaims" function belong to the firebase Admin SDK, whereas the "get-admin-users" function could be implemented also on the client side. Here and here you will find more information about custom claims, including firestore rules for client side queries protected by a custom user claim/role.