Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions functions/roleGrantPolicy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const VERIFIED_EMAIL_REQUIRED_ROLES = new Set(['admin', 'member']);

const EMAIL_UNVERIFIED_GRANT_CODE = 'email-unverified';
const EMAIL_UNVERIFIED_GRANT_MESSAGE =
'Target account is not eligible for this role';

function assertVerifiedEmailForRole(userRecord, role) {
if (!VERIFIED_EMAIL_REQUIRED_ROLES.has(role)) return;
if (userRecord?.emailVerified === true) return;

const error = new Error(EMAIL_UNVERIFIED_GRANT_MESSAGE);
error.code = EMAIL_UNVERIFIED_GRANT_CODE;
throw error;
}

module.exports = {
assertVerifiedEmailForRole,
EMAIL_UNVERIFIED_GRANT_CODE,
};
292 changes: 292 additions & 0 deletions functions/roleGrantPolicy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
jest.mock('firebase-admin', () => {
const users = new Map();
const profileUpdates = new Map();
const getUserByEmail = jest.fn(async (email) => {
if (!users.has(email)) {
const error = new Error('auth/user-not-found');
error.code = 'auth/user-not-found';
throw error;
}
return users.get(email);
});
const setCustomUserClaims = jest.fn().mockResolvedValue(undefined);
const profileUpdate = jest.fn(async (email, patch) => {
profileUpdates.set(email, patch);
});
const membersRef = {
where: jest.fn((_field, _operator, email) => ({
limit: jest.fn(() => ({
get: jest.fn(async () => ({
docs: [{
ref: {
update: (patch) => profileUpdate(email, patch),
},
}],
})),
})),
})),
};
const firestoreApi = {
collection: jest.fn((name) => {
if (name !== 'members') throw new Error(`Unexpected collection: ${name}`);
return membersRef;
}),
};
const Timestamp = { now: jest.fn(() => ({ _seconds: 1_800_000_000 })) };

return {
auth: jest.fn(() => ({ getUserByEmail, setCustomUserClaims })),
firestore: Object.assign(jest.fn(() => firestoreApi), { Timestamp }),
__setUsers: (records) => {
users.clear();
records.forEach((record) => users.set(record.email.toLowerCase(), record));
},
__reset: () => {
users.clear();
profileUpdates.clear();
getUserByEmail.mockClear();
setCustomUserClaims.mockClear();
profileUpdate.mockClear();
membersRef.where.mockClear();
firestoreApi.collection.mockClear();
Timestamp.now.mockClear();
},
__mocks: {
getUserByEmail,
setCustomUserClaims,
profileUpdate,
profileUpdates,
},
};
});

jest.mock('firebase-functions', () => {
class HttpsError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}

return {
https: {
onCall: (handler) => handler,
onRequest: (handler) => handler,
HttpsError,
},
config: () => ({ api: { key: 'test-api-key' } }),
};
});

jest.mock('./stripeHelpers', () => ({
requireAdmin: jest.fn().mockResolvedValue(undefined),
requireAppCheck: jest.fn(),
}));

const admin = require('firebase-admin');
const { setMemberRole } = require('./setMemberRole');
const { updateMemberRole } = require('./updatemembers');

const ADMIN_CONTEXT = {
auth: { uid: 'caller-admin', token: { role: 'admin' } },
app: { appId: 'test-app' },
};

function mockResponse() {
return {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
}

function bulkRequest(body) {
return {
method: 'POST',
body,
get: (header) => (header === 'X-API-Key' ? 'test-api-key' : undefined),
};
}

describe('role grant verification policy', () => {
beforeEach(() => {
admin.__reset();
});

test('requires authoritative verification for member and admin grants only', () => {
const {
assertVerifiedEmailForRole,
EMAIL_UNVERIFIED_GRANT_CODE,
} = require('./roleGrantPolicy');

expect(() => assertVerifiedEmailForRole({ emailVerified: true }, 'member'))
.not.toThrow();
expect(() => assertVerifiedEmailForRole({ emailVerified: true }, 'admin'))
.not.toThrow();
expect(() => assertVerifiedEmailForRole({ emailVerified: false }, 'unverified'))
.not.toThrow();

for (const role of ['member', 'admin']) {
for (const userRecord of [{ emailVerified: false }, {}, null]) {
try {
assertVerifiedEmailForRole(userRecord, role);
throw new Error('Expected policy rejection');
} catch (error) {
expect(error).toMatchObject({ code: EMAIL_UNVERIFIED_GRANT_CODE });
}
}
}
});

test.each(['member', 'admin'])(
'admin callable preserves verified %s promotion',
async (role) => {
admin.__setUsers([{
email: 'verified@example.com',
uid: 'verified-uid',
emailVerified: true,
}]);

await expect(setMemberRole({
email: ' Verified@Example.com ',
role,
}, ADMIN_CONTEXT)).resolves.toEqual({
ok: true,
uid: 'verified-uid',
role,
});
expect(admin.__mocks.setCustomUserClaims)
.toHaveBeenCalledWith('verified-uid', { role });
expect(admin.__mocks.profileUpdate).toHaveBeenCalledTimes(1);
},
);

test.each([
['member', false],
['admin', undefined],
])(
'admin callable rejects %s promotion when verification is %s before writes',
async (role, emailVerified) => {
admin.__setUsers([{
email: 'target@example.com',
uid: 'target-uid',
...(emailVerified === undefined ? {} : { emailVerified }),
}]);

await expect(setMemberRole({
email: 'target@example.com',
role,
emailVerified: true,
}, ADMIN_CONTEXT)).rejects.toMatchObject({
code: 'failed-precondition',
message: 'Target account is not eligible for this role',
});
expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled();
expect(admin.__mocks.profileUpdate).not.toHaveBeenCalled();
},
);

test('admin callable still permits demotion of an unverified target', async () => {
admin.__setUsers([{
email: 'target@example.com',
uid: 'target-uid',
emailVerified: false,
}]);

await expect(setMemberRole({
email: 'target@example.com',
role: 'unverified',
}, ADMIN_CONTEXT)).resolves.toMatchObject({ ok: true, role: 'unverified' });
expect(admin.__mocks.setCustomUserClaims)
.toHaveBeenCalledWith('target-uid', { role: 'unverified' });
});

test('admin callable retains self-demotion protection', async () => {
admin.__setUsers([{
email: 'caller@example.com',
uid: 'caller-admin',
emailVerified: true,
}]);

await expect(setMemberRole({
email: 'caller@example.com',
role: 'unverified',
}, ADMIN_CONTEXT)).rejects.toMatchObject({ code: 'failed-precondition' });
expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled();
});

test('bulk sync promotes verified targets and reports indistinguishable failures', async () => {
admin.__setUsers([
{
email: 'verified@example.com',
uid: 'verified-uid',
emailVerified: true,
},
{
email: 'unverified@example.com',
uid: 'unverified-uid',
emailVerified: false,
},
]);
const response = mockResponse();

await updateMemberRole(bulkRequest({
emails: [
'verified@example.com',
'unverified@example.com',
'missing@example.com',
],
role: 'member',
emailVerified: true,
}), response);

expect(response.status).toHaveBeenCalledWith(200);
expect(response.json).toHaveBeenCalledWith(expect.objectContaining({
succeeded: ['verified@example.com'],
failed: [{
email: 'unverified@example.com',
error: 'Role update not applied',
}, {
email: 'missing@example.com',
error: 'Role update not applied',
}],
role: 'member',
}));
expect(admin.__mocks.setCustomUserClaims)
.toHaveBeenCalledTimes(1);
expect(admin.__mocks.setCustomUserClaims)
.toHaveBeenCalledWith('verified-uid', { role: 'member' });
expect(admin.__mocks.profileUpdate).toHaveBeenCalledTimes(1);
expect(admin.__mocks.profileUpdates.has('unverified@example.com')).toBe(false);
});

test('bulk sync still permits unverified demotion', async () => {
admin.__setUsers([{
email: 'target@example.com',
uid: 'target-uid',
emailVerified: false,
}]);
const response = mockResponse();

await updateMemberRole(bulkRequest({
emails: ['target@example.com'],
role: 'unverified',
}), response);

expect(response.status).toHaveBeenCalledWith(200);
expect(admin.__mocks.setCustomUserClaims)
.toHaveBeenCalledWith('target-uid', { role: 'unverified' });
});

test('bulk sync continues to reject admin grants before user lookup', async () => {
const response = mockResponse();

await updateMemberRole(bulkRequest({
emails: ['target@example.com'],
role: 'admin',
}), response);

expect(response.status).toHaveBeenCalledWith(400);
expect(admin.__mocks.getUserByEmail).not.toHaveBeenCalled();
expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled();
});
});
11 changes: 11 additions & 0 deletions functions/setMemberRole.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { requireAdmin, requireAppCheck } = require('./stripeHelpers');
const {
assertVerifiedEmailForRole,
EMAIL_UNVERIFIED_GRANT_CODE,
} = require('./roleGrantPolicy');

const VALID_ROLES = ['admin', 'member', 'unverified'];

Expand Down Expand Up @@ -35,6 +39,13 @@ exports.setMemberRole = functions.https.onCall(async (data, context) => {
);
}

try {
assertVerifiedEmailForRole(userRecord, role);
} catch (error) {
if (error.code !== EMAIL_UNVERIFIED_GRANT_CODE) throw error;
throw new functions.https.HttpsError('failed-precondition', error.message);
}

await admin.auth().setCustomUserClaims(userRecord.uid, { role });

const membersRef = admin.firestore().collection('members');
Expand Down
5 changes: 4 additions & 1 deletion functions/updatemembers.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { assertVerifiedEmailForRole } = require('./roleGrantPolicy');

// Deliberately excludes 'admin': this endpoint is gated by a static API key,
// and a leaked key must not be able to mint admins. Admin grants go through
// the setMemberRole callable, which requires an authenticated admin caller.
const GRANTABLE_ROLES = ['member', 'unverified'];
const MAX_EMAILS_PER_REQUEST = 100;
const BULK_ROLE_UPDATE_FAILURE = 'Role update not applied';

/**
* Validates an email address format
Expand All @@ -20,6 +22,7 @@ function isValidEmail(email) {
*/
async function updateSingleMemberRole(email, role) {
const userRecord = await admin.auth().getUserByEmail(email);
assertVerifiedEmailForRole(userRecord, role);
await admin.auth().setCustomUserClaims(userRecord.uid, { role });

const membersRef = admin.firestore().collection('members');
Expand Down Expand Up @@ -113,7 +116,7 @@ exports.updateMemberRole = functions.https.onRequest(
} else {
failed.push({
email: emails[index],
error: result.reason?.message || 'Unknown error',
error: BULK_ROLE_UPDATE_FAILURE,
});
}
});
Expand Down
Loading