Skip to content

Commit

Permalink
Election manager/poll worker auth (#39)
Browse files Browse the repository at this point in the history
* Tweak auth to allow dipped poll worker auth

* Add dev-dock

* Add libs/fujitsu-scanner and libs/pdi-scanner

dev-dock deps

* Set up basic auth and election manager screen

* Check smart card election key and add unconfigure flow

Convert the election configuration type to match VxSuite Election type

* Remove pdi-scanner

* Make sure you can log out of EM during configuration

* resolve rebase issues
  • Loading branch information
jonahkagan authored Jan 13, 2025
1 parent f1ee9f2 commit 1b09884
Show file tree
Hide file tree
Showing 89 changed files with 5,731 additions and 192 deletions.
6 changes: 6 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
REACT_APP_VX_ENABLE_REACT_QUERY_DEVTOOLS=FALSE
REACT_APP_VX_ENABLE_DEV_DOCK=TRUE
REACT_APP_VX_SKIP_PIN_ENTRY=FALSE
REACT_APP_VX_USE_MOCK_CARDS=FALSE
REACT_APP_VX_USE_MOCK_USB_DRIVE=FALSE
REACT_APP_VX_USE_MOCK_PRINTER=FALSE
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
"type-check": "tsc --build"
},
"dependencies": {
"@votingworks/auth": "workspace:*",
"@votingworks/backend": "workspace:*",
"@votingworks/basics": "workspace:*",
"@votingworks/db": "workspace:*",
"@votingworks/dev-dock-backend": "workspace:*",
"@votingworks/fs": "workspace:*",
"@votingworks/grout": "workspace:*",
"@votingworks/logging": "workspace:*",
Expand Down
144 changes: 99 additions & 45 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ import { join } from 'node:path';
import {
getEntries,
getFileByName,
isElectionManagerAuth,
openZip,
readTextEntry,
} from '@votingworks/utils';
import { safeParseJson } from '@votingworks/types';
import { DEFAULT_SYSTEM_SETTINGS, safeParseJson } from '@votingworks/types';
import { parse } from 'csv-parse/sync';
import { setInterval } from 'node:timers/promises';
import { exec } from 'node:child_process';
import {
DippedSmartCardAuthApi,
DippedSmartCardAuthMachineState,
} from '@votingworks/auth';
import { Workspace } from './workspace';
import {
ElectionConfiguration,
ElectionConfigurationSchema,
Election,
ElectionSchema,
PollbookPackage,
Voter,
VoterIdentificationMethod,
Expand All @@ -34,6 +38,7 @@ import {
const debug = rootDebug;

export interface AppContext {
auth: DippedSmartCardAuthApi;
workspace: Workspace;
usbDrive: UsbDrive;
machineId: string;
Expand All @@ -59,6 +64,19 @@ function createApiClientForAddress(address: string): grout.Client<Api> {
});
}

function constructAuthMachineState(
workspace: Workspace
): DippedSmartCardAuthMachineState {
const election = workspace.store.getElection();
return {
...DEFAULT_SYSTEM_SETTINGS['auth'],
electionKey: election && {
id: election.id,
date: election.date,
},
};
}

async function readPollbookPackage(
path: string
): Promise<Result<PollbookPackage, ReadFileError>> {
Expand All @@ -74,9 +92,9 @@ async function readPollbookPackage(

const electionEntry = getFileByName(entries, 'election.json', zipName);
const electionJsonString = await readTextEntry(electionEntry);
const election: ElectionConfiguration = safeParseJson(
const election: Election = safeParseJson(
electionJsonString,
ElectionConfigurationSchema
ElectionSchema
).unsafeUnwrap();

const votersEntry = getFileByName(entries, 'voters.csv', zipName);
Expand All @@ -94,48 +112,62 @@ async function readPollbookPackage(
type ConfigurationStatus = 'loading' | 'not-found';
let configurationStatus: ConfigurationStatus | undefined;

function pollUsbDriveForPollbookPackage({ workspace, usbDrive }: AppContext) {
function pollUsbDriveForPollbookPackage({
auth,
workspace,
usbDrive,
}: AppContext) {
debug('Polling USB drive for pollbook package');
if (workspace.store.getElectionConfiguration()) {
if (workspace.store.getElection()) {
return;
}
process.nextTick(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of setInterval(100)) {
const usbDriveStatus = await usbDrive.status();
if (usbDriveStatus.status === 'mounted') {
debug('Found USB drive mounted at %s', usbDriveStatus.mountPoint);
configurationStatus = 'loading';
const pollbookPackageResult = await readPollbookPackage(
join(usbDriveStatus.mountPoint, 'pollbook-package.zip')
);
if (pollbookPackageResult.isErr()) {
const result = pollbookPackageResult.err();
debug('Read pollbook package error: %O', result);
if (
result.type === 'OpenFileError' &&
'code' in result.error &&
result.error.code === 'ENOENT'
) {
configurationStatus = 'not-found';
} else {
throw result;
}
if (usbDriveStatus.status !== 'mounted') {
continue;
}
debug('Found USB drive mounted at %s', usbDriveStatus.mountPoint);

const authStatus = await auth.getAuthStatus(
constructAuthMachineState(workspace)
);
if (!isElectionManagerAuth(authStatus)) {
debug('Not logged in as election manager, not configuring');
continue;
}

configurationStatus = 'loading';
const pollbookPackageResult = await readPollbookPackage(
join(usbDriveStatus.mountPoint, 'pollbook-package.zip')
);
if (pollbookPackageResult.isErr()) {
const result = pollbookPackageResult.err();
debug('Read pollbook package error: %O', result);
if (
result.type === 'OpenFileError' &&
'code' in result.error &&
result.error.code === 'ENOENT'
) {
configurationStatus = 'not-found';
continue;
} else {
throw result;
}
const pollbookPackage = pollbookPackageResult.ok();
workspace.store.setElectionAndVoters(
pollbookPackage.election,
pollbookPackage.voters
);
configurationStatus = undefined;
debug('Configured with pollbook package: %O', {
election: pollbookPackage.election,
voters: pollbookPackage.voters.length,
});
break;
configurationStatus = 'not-found';
continue;
}
const pollbookPackage = pollbookPackageResult.ok();
workspace.store.setElectionAndVoters(
pollbookPackage.election,
pollbookPackage.voters
);
configurationStatus = undefined;
debug('Configured with pollbook package: %O', {
election: pollbookPackage.election,
voters: pollbookPackage.voters.length,
});
break;
}
});
}
Expand Down Expand Up @@ -195,21 +227,44 @@ async function setupMachineNetworking({
});
}

function buildApi({ workspace, machineId }: AppContext) {
function buildApi(context: AppContext) {
const { auth, workspace, usbDrive, machineId } = context;
const { store } = workspace;

return grout.createApi({
getElectionConfiguration(): Result<
ElectionConfiguration,
'unconfigured' | ConfigurationStatus
> {
getAuthStatus() {
return auth.getAuthStatus(constructAuthMachineState(workspace));
},

checkPin(input: { pin: string }) {
return auth.checkPin(constructAuthMachineState(workspace), input);
},

logOut() {
return auth.logOut(constructAuthMachineState(workspace));
},

updateSessionExpiry(input: { sessionExpiresAt: Date }) {
return auth.updateSessionExpiry(
constructAuthMachineState(workspace),
input
);
},

getElection(): Result<Election, 'unconfigured' | ConfigurationStatus> {
if (configurationStatus) {
return err(configurationStatus);
}
const election = store.getElectionConfiguration();
const election = store.getElection();
return election ? ok(election) : err('unconfigured');
},

async unconfigure(): Promise<void> {
store.deleteElectionAndVoters();
await usbDrive.eject();
pollUsbDriveForPollbookPackage(context);
},

searchVoters(input: {
searchParams: VoterSearchParams;
}): Voter[] | number | null {
Expand Down Expand Up @@ -256,7 +311,6 @@ export function buildApp(context: AppContext): Application {
const app: Application = express();
const api = buildApi(context);
app.use('/api', grout.buildRouter(api, express));
app.use(express.static(context.workspace.assetDirectoryPath));

pollUsbDriveForPollbookPackage(context);

Expand Down
2 changes: 1 addition & 1 deletion backend/src/backup_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function start({
process.nextTick(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of setInterval(BACKUP_INTERVAL)) {
if (!workspace.store.getElectionConfiguration()) {
if (!workspace.store.getElection()) {
console.log('Machine not configured, skipping backup');
continue;
}
Expand Down
28 changes: 24 additions & 4 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { resolve } from 'node:path';
import { loadEnvVarsFromDotenvFiles } from '@votingworks/backend';
import { BaseLogger, Logger, LogSource } from '@votingworks/logging';
import { detectUsbDrive } from '@votingworks/usb-drive';
import {
isFeatureFlagEnabled,
BooleanEnvironmentVariableName,
isIntegrationTest,
} from '@votingworks/utils';
import { DippedSmartCardAuth, MockFileCard, JavaCard } from '@votingworks/auth';
import { WORKSPACE } from './globals';
import * as server from './server';
import * as backupWorker from './backup_worker';
Expand All @@ -22,16 +28,30 @@ function main(): Promise<number> {
);
}
const workspacePath = resolve(WORKSPACE);
const logger = new BaseLogger(LogSource.System);
const workspace = createWorkspace(workspacePath, logger);
const baseLogger = new BaseLogger(LogSource.System);

const usbDrive = detectUsbDrive(
Logger.from(logger, () => Promise.resolve('system'))
Logger.from(baseLogger, () => Promise.resolve('system'))
);

const auth = new DippedSmartCardAuth({
card:
isFeatureFlagEnabled(BooleanEnvironmentVariableName.USE_MOCK_CARDS) ||
isIntegrationTest()
? new MockFileCard()
: new JavaCard(),
config: {
allowElectionManagersToAccessUnconfiguredMachines: true,
},
logger: baseLogger,
});

const workspace = createWorkspace(workspacePath, baseLogger);

server.start({
workspace,
auth,
usbDrive,
workspace,
machineId: process.env.VX_MACHINE_ID || 'dev',
});
backupWorker.start({ workspace, usbDrive });
Expand Down
4 changes: 4 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useDevDockRouter } from '@votingworks/dev-dock-backend';
import express from 'express';
import { AppContext, buildApp } from './app';
import { PORT } from './globals';

Expand All @@ -7,6 +9,8 @@ import { PORT } from './globals';
export function start(context: AppContext): void {
const app = buildApp(context);

useDevDockRouter(app, express, {});

app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`VxPollbook backend running at http://localhost:${PORT}/`);
Expand Down
20 changes: 11 additions & 9 deletions backend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BaseLogger } from '@votingworks/logging';
import { assert, find, groupBy } from '@votingworks/basics';
import { rootDebug } from './debug';
import {
ElectionConfiguration,
Election,
PollBookService,
Voter,
VoterIdentificationMethod,
Expand All @@ -18,7 +18,7 @@ const debug = rootDebug;

const data: {
voters?: Voter[];
electionConfiguration?: ElectionConfiguration;
election?: Election;
connectedPollbooks?: Record<string, PollBookService>;
} = {};

Expand Down Expand Up @@ -51,18 +51,20 @@ export class Store {
return new Store(DbClient.memoryClient(SchemaPath));
}

getElectionConfiguration(): ElectionConfiguration | undefined {
return data.electionConfiguration;
getElection(): Election | undefined {
return data.election;
}

setElectionAndVoters(
electionConfiguration: ElectionConfiguration,
voters: Voter[]
): void {
data.electionConfiguration = electionConfiguration;
setElectionAndVoters(election: Election, voters: Voter[]): void {
data.election = election;
data.voters = voters;
}

deleteElectionAndVoters(): void {
data.election = undefined;
data.voters = undefined;
}

groupVotersAlphabeticallyByLastName(): Array<Voter[]> {
assert(data.voters);
return groupBy(data.voters, (v) => v.lastName[0].toUpperCase()).map(
Expand Down
Loading

0 comments on commit 1b09884

Please sign in to comment.