diff --git a/packages/backend-lib/src/admin/admin.resource.test.ts b/packages/backend-lib/src/admin/admin.resource.test.ts index 72434af99..059533037 100644 --- a/packages/backend-lib/src/admin/admin.resource.test.ts +++ b/packages/backend-lib/src/admin/admin.resource.test.ts @@ -1,7 +1,9 @@ import { MOCK_TS_2018_06_21 } from '@naturalcycles/dev-lib/testing/time' +import { _expectedError, AssertionError } from '@naturalcycles/js-lib/error' import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { getDefaultRouter } from '../express/getDefaultRouter.js' import type { BackendRequest } from '../server/server.model.js' +import { mockBackendRequest } from '../test/mocks.js' import { expressTestService } from '../testing/index.js' import { createAdminMiddleware } from './adminMiddleware.js' import { BaseAdminService } from './base.admin.service.js' @@ -39,11 +41,16 @@ const adminService = new AdminService(() => firebaseService.auth(), { // authEnabled: false, }) +const adminServiceAuthDisabled = new AdminService(() => firebaseService.auth(), { + authEnabled: false, +}) + const adminResource = getDefaultRouter() const requireAdmin = createAdminMiddleware(adminService) adminResource.get('/admin/info', async (req, res) => { - res.json((await adminService.getAdminInfo(req)) || null) + const adminInfo = await adminService.getAdminInfo(req) + res.json(adminInfo || null) }) adminResource.post('/admin/login', adminService.getFirebaseAuthLoginHandler()) adminResource.get( @@ -98,7 +105,8 @@ afterAll(async () => { await app.close() // Clean up Firebase app to avoid polluting other tests const { deleteApp } = await import('firebase-admin/app') - await deleteApp(await firebaseService.admin()) + const firebaseApp = await firebaseService.admin() + await deleteApp(firebaseApp) }) describe('login', () => { @@ -294,3 +302,169 @@ describe('createAdminMiddleware', () => { expect(success).toBe(true) }) }) + +describe('isAdmin', () => { + test('should resolve the admin even if auth is disabled', async () => { + vi.spyOn(adminServiceAuthDisabled, 'getEmailByToken').mockResolvedValue('p1@mail.com') + const req = mockBackendRequest({ headers: { 'x-admin-token': 'p1' } }) + + const isAdmin = await adminServiceAuthDisabled.isAdmin(req) + expect(isAdmin).toBe(true) + }) +}) + +describe('resolveAdmin', () => { + test('should resolve email and permissions of an admin', async () => { + vi.spyOn(adminService, 'getEmailByToken').mockResolvedValue('p1@mail.com') + const req = mockBackendRequest({ headers: { 'x-admin-token': 'p1' } }) + + const admin = await adminService.resolveAdmin(req) + expect(admin).toEqual({ + email: 'p1@mail.com', + permissions: new Set(['p1']), + }) + expect(adminService.getEmailByToken).toHaveBeenCalledWith(req, 'p1') + }) + + test('should resolve email without permissions if not an admin', async () => { + vi.spyOn(adminService, 'getEmailByToken').mockResolvedValue('notAdmin@mail.com') + const req = mockBackendRequest({ headers: { 'x-admin-token': 'notAdmin' } }) + + const admin = await adminService.resolveAdmin(req) + expect(admin).toEqual({ + email: 'notAdmin@mail.com', + permissions: undefined, + }) + }) + + test('should resolve to empty object if token is invalid', async () => { + vi.spyOn(adminService, 'getEmailByToken').mockResolvedValue(undefined) + const req = mockBackendRequest({ headers: { 'x-admin-token': 'invalid' } }) + + const admin = await adminService.resolveAdmin(req) + expect(admin).toEqual({}) + }) +}) + +describe('hasPermissions', () => { + test('should return undefined if some required permission is missing', async () => { + vi.spyOn(adminService, 'getEmailByToken').mockResolvedValue('p1@mail.com') + const req = mockBackendRequest({ headers: { 'x-admin-token': 'p1' } }) + + const adminInfo = await adminService.hasPermissions(req, ['p1', 'p2']) + expect(adminInfo).toBeUndefined() + }) +}) + +describe('checkPermissions', () => { + test('should grant if all required permissions are present on AND-comparison', () => { + const admin = { email: 'p1p2@mail.com', permissions: new Set(['p1', 'p2']) } + + expect(adminService.checkPermissions(admin, ['p1', 'p2'])).toEqual({ + email: 'p1p2@mail.com', + isAdmin: true, + granted: true, + reqPermissions: ['p1', 'p2'], + grantedPermissions: ['p1', 'p2'], + authDisabled: false, + }) + }) + + test('should deny if some required permission is missing on AND-comparison', () => { + const admin = { email: 'p1@mail.com', permissions: new Set(['p1']) } + + expect(adminService.checkPermissions(admin, ['p1', 'p2'])).toEqual({ + email: 'p1@mail.com', + isAdmin: true, + granted: false, + reqPermissions: ['p1', 'p2'], + grantedPermissions: ['p1'], + authDisabled: false, + }) + }) + + test('should grant if one required permission is present on OR-comparison', () => { + const admin = { email: 'p1@mail.com', permissions: new Set(['p1']) } + + expect(adminService.checkPermissions(admin, ['p1', 'p2'], { andComparison: false })).toEqual({ + email: 'p1@mail.com', + isAdmin: true, + granted: true, + reqPermissions: ['p1', 'p2'], + grantedPermissions: ['p1'], + authDisabled: false, + }) + }) + + test('should deny if the email is not an admin', () => { + const admin = { email: 'notAdmin@mail.com' } + + expect(adminService.checkPermissions(admin, ['p1'])).toEqual({ + email: 'notAdmin@mail.com', + isAdmin: false, + granted: false, + reqPermissions: ['p1'], + grantedPermissions: [], + authDisabled: false, + }) + }) + + test('should grant if auth is disabled', () => { + expect(adminServiceAuthDisabled.checkPermissions({}, ['p1'])).toEqual({ + email: undefined, + isAdmin: false, + granted: true, + reqPermissions: ['p1'], + grantedPermissions: [], + authDisabled: true, + }) + }) +}) + +describe('requireGranted', () => { + test('should return AdminInfo with the granted permissions', () => { + const result = { + email: 'p1@mail.com', + isAdmin: true, + granted: true, + reqPermissions: ['p1', 'p2'], + grantedPermissions: ['p1'], + authDisabled: false, + } + + expect(adminService.requireGranted(result)).toEqual({ + email: 'p1@mail.com', + permissions: ['p1'], + }) + }) + + test('should throw 401 if there is no email', () => { + const result = { + isAdmin: false, + granted: false, + reqPermissions: ['p1'], + grantedPermissions: [], + authDisabled: false, + } + + const err = _expectedError(() => adminService.requireGranted(result), AssertionError) + + expect(err.data.backendResponseStatusCode).toBe(401) + }) + + test('should throw 403 if not granted', () => { + const result = { + email: 'p1@mail.com', + isAdmin: true, + granted: false, + reqPermissions: ['p1', 'p2'], + grantedPermissions: ['p1'], + authDisabled: false, + } + + const err = _expectedError(() => adminService.requireGranted(result), AssertionError) + + expect(err.data.backendResponseStatusCode).toBe(403) + expect(err.data['adminPermissionsRequired']).toEqual(['p1', 'p2']) + }) +}) diff --git a/packages/backend-lib/src/admin/base.admin.service.ts b/packages/backend-lib/src/admin/base.admin.service.ts index 350f85218..c9da4b7a3 100644 --- a/packages/backend-lib/src/admin/base.admin.service.ts +++ b/packages/backend-lib/src/admin/base.admin.service.ts @@ -1,33 +1,10 @@ import { _Memo } from '@naturalcycles/js-lib/decorators' -import { _assert, AppError } from '@naturalcycles/js-lib/error' +import { _assert } from '@naturalcycles/js-lib/error' +import type { AnyObject } from '@naturalcycles/js-lib/types' import { dimGrey, green, red } from '@naturalcycles/nodejs-lib/colors' import type { Auth } from 'firebase-admin/auth' import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js' -export interface AdminServiceCfg { - /** - * @default 'admin_token' - */ - adminTokenKey?: string - - /** - * If false - disables auth completely (useful for debugging locally, but never in production). - * - * @default true - */ - authEnabled?: boolean -} - -export interface AdminInfo { - email: string - permissions: string[] -} - -const adminInfoDisabled = (): AdminInfo => ({ - email: 'authDisabled', - permissions: [], -}) - /** * Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie. */ @@ -52,87 +29,10 @@ export class BaseAdminService { } } - /** - * To be extended. - * - * Returns undefined if it's not an Admin. - * Otherwise returns Set of permissions. - * Empty array means it IS and Admin, but has no permissions (except being an Admin). - */ - async getEmailPermissions(email?: string): Promise | undefined> { - if (!email) return - console.log( - `getEmailPermissions (${dimGrey( - email, - )}) returning undefined (please override the implementation)`, - ) - } - - /** - * To be extended. - */ - // oxlint-disable-next-line max-params - protected async onPermissionCheck( - req: BackendRequest, - email: string, - reqPermissions: string[], - required: boolean, - granted: boolean, - meta: Record = {}, - ): Promise { - req.log( - `${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey( - reqPermissions.join(', '), - )}]: ${granted ? green('GRANTED') : red('DENIED')}`, - meta, - ) - } - - async getEmailByToken(req: BackendRequest, adminToken?: string): Promise { - if (!adminToken) return - - try { - const auth = await this.getFirebaseAuth() - const decodedToken = await auth.verifyIdToken(adminToken) - const email = decodedToken?.email - req.log(`admin email: ${dimGrey(email)}`) - return email - } catch (err) { - // example: - // FirebaseAuthError: Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired). - if ( - // err instanceof FirebaseAuthError && err.hasCode('id-token-expired') - (err as any)?.code?.includes('id-token-expired') - ) { - return // skip logging, expected error - } - - req.error(`getEmailByToken error:`, err) - } - } - - @_Memo() - private async getFirebaseAuth(): Promise { - return await this.loadFirebaseAuth() - } - - /** - * Current implementation is based on req=Request (from Express). - * Override if needed. - */ - getAdminToken(req: BackendRequest): string | undefined { - return ( - req.cookies?.[this.cfg.adminTokenKey] || - req.header(this.cfg.adminTokenKey) || - req.header('x-admin-token') - ) - } - async isAdmin(req: BackendRequest | undefined): Promise { if (!req) return false - const adminToken = this.getAdminToken(req) - const email = await this.getEmailByToken(req, adminToken) - return !!(await this.getEmailPermissions(email)) + const { permissions } = await this.resolveAdmin(req) + return !!permissions } async getAdminInfo(req: BackendRequest): Promise { @@ -144,6 +44,24 @@ export class BaseAdminService { // await this.reqPermissions(req) // } + // convenience method + async hasPermission( + req: BackendRequest, + reqPermission: string, + meta?: AnyObject, + ): Promise { + const adminInfo = await this.hasPermissions(req, [reqPermission], meta) + return !!adminInfo + } + + async requirePermission( + req: BackendRequest, + reqPermission: string, + meta?: AnyObject, + ): Promise { + return await this.requirePermissions(req, [reqPermission], meta) + } + /** * Returns AdminInfo if it has all required permissions. * Otherwise returns undefined @@ -151,73 +69,119 @@ export class BaseAdminService { async hasPermissions( req: BackendRequest, reqPermissions: string[] = [], - meta: Record = {}, + meta: AnyObject = {}, ): Promise { if (!this.cfg.authEnabled) return adminInfoDisabled() - const adminToken = this.getAdminToken(req) - const email = await this.getEmailByToken(req, adminToken) - const hasPermissions = await this.getEmailPermissions(email) - if (!hasPermissions) return + const admin = await this.resolveAdmin(req) + const { email, permissions } = admin + if (!email || !permissions) return - const granted = reqPermissions.every(p => hasPermissions.has(p)) + const result = this.checkPermissions(admin, reqPermissions) - void this.onPermissionCheck(req, email!, reqPermissions, false, granted, meta) + void this.onPermissionCheck(req, email, reqPermissions, false, result.granted, meta) - if (!granted) return + if (!result.granted) return return { - email: email!, - permissions: Array.from(hasPermissions), + email, + permissions: Array.from(permissions), } } async requirePermissions( req: BackendRequest, reqPermissions: string[] = [], - meta: Record = {}, + meta: AnyObject = {}, andComparison = true, ): Promise { if (!this.cfg.authEnabled) return adminInfoDisabled() + const admin = await this.resolveAdmin(req) + const result = this.checkPermissions(admin, reqPermissions, { andComparison }) + + if (result.email) { + // Log only the granted permissions (differs from reqPermissions only on OR-comparison) + const checkedPermissions = result.granted ? result.grantedPermissions : reqPermissions + void this.onPermissionCheck(req, result.email, checkedPermissions, true, result.granted, meta) + } + + return this.requireGranted(result) + } + + /** + * Doesn't check permissions, nor `authEnabled`. See `checkPermissions`. + */ + async resolveAdmin(req: BackendRequest): Promise { const adminToken = this.getAdminToken(req) const email = await this.getEmailByToken(req, adminToken) + if (!email) return {} - if (!email) { - throw new AppError('adminToken required', { - adminAuthRequired: true, - backendResponseStatusCode: 401, - userFriendly: true, - }) + const permissions = await this.getEmailPermissions(email) + return { email, permissions } + } + + /** + * Doesn't log or throw. See `requireGranted`. + */ + checkPermissions( + admin: ResolvedAdmin, + reqPermissions: string[] = [], + opt: CheckPermissionsOptions = {}, + ): CheckPermissionsResult { + const { andComparison = true } = opt + const { email, permissions: hasPermissions } = admin + const isAdmin = !!hasPermissions + + if (!this.cfg.authEnabled) { + return { + email, + isAdmin, + granted: true, + reqPermissions, + grantedPermissions: [], + authDisabled: true, + } } - const hasPermissions = await this.getEmailPermissions(email) const grantedPermissions = hasPermissions ? reqPermissions.filter(p => hasPermissions.has(p)) : [] - let granted: boolean - if (andComparison) { - granted = !!hasPermissions && grantedPermissions.length === reqPermissions.length // All permissions granted - void this.onPermissionCheck(req, email, reqPermissions, true, granted, meta) - } else { - granted = !!hasPermissions && grantedPermissions.length > 0 - if (granted) { - // Require the permission(s), but only the ones the user was actually granted. 1+ is required - void this.onPermissionCheck(req, email, grantedPermissions, true, granted, meta) - } else { - void this.onPermissionCheck(req, email, reqPermissions, true, granted, meta) - } - } + const granted = andComparison + ? isAdmin && grantedPermissions.length === reqPermissions.length // All permissions granted + : isAdmin && grantedPermissions.length > 0 // 1+ is required - if (!granted) { - throw new AppError(`Admin permissions required: [${reqPermissions.join(', ')}]`, { - adminPermissionsRequired: reqPermissions, - email, - backendResponseStatusCode: 403, - userFriendly: true, - }) + return { + email, + isAdmin, + granted, + reqPermissions, + grantedPermissions, + authDisabled: false, } + } + + /** + * Throws 401 if there's no email, 403 if not granted. + */ + requireGranted(result: CheckPermissionsResult): AdminInfo { + if (result.authDisabled) return adminInfoDisabled() + + const { email, granted, reqPermissions, grantedPermissions } = result + + _assert(email, 'adminToken required', { + adminAuthRequired: true, + backendResponseStatusCode: 401, + userFriendly: true, + }) + + _assert(granted, `Admin permissions required: [${reqPermissions.join(', ')}]`, { + adminPermissionsRequired: reqPermissions, + email, + backendResponseStatusCode: 403, + userFriendly: true, + }) return { email, @@ -225,21 +189,80 @@ export class BaseAdminService { } } - // convenience method - async hasPermission( - req: BackendRequest, - reqPermission: string, - meta?: Record, - ): Promise { - return !!(await this.hasPermissions(req, [reqPermission], meta)) + /** + * Current implementation is based on req=Request (from Express). + * Override if needed. + */ + getAdminToken(req: BackendRequest): string | undefined { + return ( + req.cookies?.[this.cfg.adminTokenKey] || + req.header(this.cfg.adminTokenKey) || + req.header('x-admin-token') + ) } - async requirePermission( + async getEmailByToken(req: BackendRequest, adminToken?: string): Promise { + if (!adminToken) return + + try { + const auth = await this.getFirebaseAuth() + const decodedToken = await auth.verifyIdToken(adminToken) + const email = decodedToken?.email + req.log(`admin email: ${dimGrey(email)}`) + return email + } catch (err) { + // example: + // FirebaseAuthError: Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired). + if ( + // err instanceof FirebaseAuthError && err.hasCode('id-token-expired') + (err as any)?.code?.includes('id-token-expired') + ) { + return // skip logging, expected error + } + + req.error(`getEmailByToken error:`, err) + } + } + + @_Memo() + private async getFirebaseAuth(): Promise { + return await this.loadFirebaseAuth() + } + + /** + * To be extended. + * + * Returns undefined if it's not an Admin. + * Otherwise returns Set of permissions. + * Empty array means it IS and Admin, but has no permissions (except being an Admin). + */ + async getEmailPermissions(email?: string): Promise | undefined> { + if (!email) return + console.log( + `getEmailPermissions (${dimGrey( + email, + )}) returning undefined (please override the implementation)`, + ) + } + + /** + * To be extended. + */ + // oxlint-disable-next-line max-params + protected async onPermissionCheck( req: BackendRequest, - reqPermission: string, - meta?: Record, - ): Promise { - return await this.requirePermissions(req, [reqPermission], meta) + email: string, + reqPermissions: string[], + required: boolean, + granted: boolean, + meta: AnyObject = {}, + ): Promise { + req.log( + `${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey( + reqPermissions.join(', '), + )}]: ${granted ? green('GRANTED') : red('DENIED')}`, + meta, + ) } /** @@ -280,3 +303,53 @@ export class BaseAdminService { } } } + +const adminInfoDisabled = (): AdminInfo => ({ + email: 'authDisabled', + permissions: [], +}) + +export interface AdminServiceCfg { + /** + * @default 'admin_token' + */ + adminTokenKey?: string + + /** + * If false - disables auth completely (useful for debugging locally, but never in production). + * + * @default true + */ + authEnabled?: boolean +} + +export interface AdminInfo { + email: string + permissions: string[] +} + +export interface ResolvedAdmin { + /** undefined - no valid admin token */ + email?: string + /** undefined - not an Admin */ + permissions?: Set +} + +export interface CheckPermissionsOptions { + /** + * false - one granted permission is enough. + * + * @default true + */ + andComparison?: boolean +} + +export interface CheckPermissionsResult { + /** undefined - no valid admin token */ + email?: string + isAdmin: boolean + granted: boolean + reqPermissions: string[] + grantedPermissions: string[] + authDisabled: boolean +} diff --git a/packages/backend-lib/src/test/mocks.ts b/packages/backend-lib/src/test/mocks.ts new file mode 100644 index 000000000..7ed25f4a8 --- /dev/null +++ b/packages/backend-lib/src/test/mocks.ts @@ -0,0 +1,20 @@ +import { _noop } from '@naturalcycles/js-lib/types' +import type { BackendRequest } from '../server/server.model.js' + +export function mockBackendRequest(opts: Partial = {}): BackendRequest { + const headers = opts.headers || {} + + const req: Partial = { + debug: _noop, + log: _noop, + warn: _noop, + error: _noop, + cookies: {}, + header: ((name: string) => headers[name.toLowerCase()]) as BackendRequest['header'], + ...opts, + headers, + } + + // Express Request has too many members to mock fully, only the commonly used ones are provided + return req as BackendRequest +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fc713efc8..3dc5dcf71 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,8 +18,6 @@ minimumReleaseAgeExclude: - '@oxlint-tsgolint/*' autoDedupe: true trustLockfile: true -trustPolicyExclude: - - why-is-node-running@3.2.2 trustPolicyExcludePrune: true trustPolicy: no-downgrade blockExoticSubdeps: true