Skip to content

Commit

Permalink
Merge pull request #456 from mikefranze/DB-Updates
Browse files Browse the repository at this point in the history
Election creation dates, IP hashes, migration scripts
  • Loading branch information
mikefranze authored Jan 30, 2024
2 parents 9efe111 + 9a0fb84 commit 9c2e452
Show file tree
Hide file tree
Showing 23 changed files with 170 additions and 83 deletions.
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"start": "npm run-script build && node ./build/backend/src/index.js",
"dev": "nodemon ./src/index.ts",
"build": "tsc --project ./",
"migrate:latest": "node ./build/backend/src/migrate-to-latest.js"
"migrate:latest": "node ./build/backend/src/Migrators/migrate-to-latest.js",
"migrate:up": "node ./build/backend/src/Migrators/migrate-up.js",
"migrate:down": "node ./build/backend/src/Migrators/migrate-down.js"
},
"keywords": [],
"author": "",
Expand Down
8 changes: 6 additions & 2 deletions backend/src/Controllers/controllerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { BadRequest, InternalServerError, Unauthorized } from "@curveball/http-e
import { Request, Response } from 'express';
import { roles } from "../../../domain_model/roles";
import { hasPermission, permission, permissions } from '../../../domain_model/permissions';
import { randomUUID } from "crypto";
import { randomUUID, createHash } from "crypto";
import ServiceLocator from "../ServiceLocator";

const accountService = ServiceLocator.accountService();

export function expectValidElectionFromRequest(req:IRequest):Election {
const inputElection = req.body.Election;
inputElection.election_id = randomUUID();
inputElection.create_date = new Date().toISOString()
const validationErr = electionValidation(inputElection);
if (validationErr) {
Logger.info(req, "Invalid Election: " + validationErr, inputElection);
Expand All @@ -38,4 +38,8 @@ export function expectPermission(roles:roles[],permission:permission):any {
if (!roles.some( (role) => permission.includes(role))){
throw new Unauthorized("Does not have permission")
}
}

export function hashString(inputString: string) {
return createHash('sha256').update(inputString).digest('hex')
}
3 changes: 2 additions & 1 deletion backend/src/Controllers/registerVoterController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Election } from "../../../domain_model/Election";
import { randomUUID } from "crypto";
import { IElectionRequest } from "../IRequest";
import { Response, NextFunction } from 'express';
import { hashString } from "./controllerUtils";

const registerVoter = async (req: IElectionRequest, res: Response, next: NextFunction) => {
Logger.info(req, `${className}.registerVoter ${req.election.election_id}`);
Expand Down Expand Up @@ -43,7 +44,7 @@ const registerVoter = async (req: IElectionRequest, res: Response, next: NextFun
election_id: req.election.election_id,
email: req.user?.email,
submitted: false,
ip_address: targetElection.settings.voter_authentication.ip_address ? req.ip : undefined,
ip_hash: targetElection.settings.voter_authentication.ip_address ? hashString(req.ip) : undefined,
state: ElectionRollState.registered,
history: [],
registration: req.body.registration,
Expand Down
17 changes: 9 additions & 8 deletions backend/src/Controllers/voterRollUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,25 @@ import Logger from "../Services/Logging/Logger";
import { BadRequest, InternalServerError, Unauthorized } from "@curveball/http-errors";
import { ILoggingContext } from "../Services/Logging/ILogger";
import { randomUUID } from "crypto";
import { hashString } from "./controllerUtils";

const ElectionRollModel = ServiceLocator.electionRollDb();

export async function getOrCreateElectionRoll(req: IRequest, election: Election, ctx: ILoggingContext): Promise<ElectionRoll | null> {
// Checks for existing election roll for user
Logger.info(req, `getOrCreateElectionRoll`)

const ip_hash = hashString(req.ip)
// Get data that is used for voter authentication
const ip_address = election.settings.voter_authentication.ip_address ? req.ip : null
const require_ip_hash = election.settings.voter_authentication.ip_address ? ip_hash : null
const email = election.settings.voter_authentication.email ? req.user?.email : null
let voter_id = election.settings.voter_authentication.voter_id ? req.cookies?.voter_id : null

// Get all election roll entries that match any of the voter authentication fields
// This is an odd way of going about this, rather than getting a roll that matches all three we get all that match any of the fields and
// check the output for a number of edge cases.
var electionRollEntries = null
if ((ip_address || email || voter_id)) {
electionRollEntries = await ElectionRollModel.getElectionRoll(String(election.election_id), voter_id, email, ip_address, ctx);
if ((require_ip_hash || email || voter_id)) {
electionRollEntries = await ElectionRollModel.getElectionRoll(String(election.election_id), voter_id, email, require_ip_hash, ctx);
}


Expand All @@ -46,12 +47,12 @@ export async function getOrCreateElectionRoll(req: IRequest, election: Election,
election_id: String(election.election_id),
email: req.user?.email ? req.user.email : undefined,
voter_id: new_voter_id,
ip_address: req.ip,
ip_hash: ip_hash,
submitted: false,
state: ElectionRollState.approved,
history: history,
}]
if ((ip_address || email || voter_id)) {
if ((require_ip_hash || email || voter_id)) {
const newElectionRoll = await ElectionRollModel.submitElectionRoll(roll, ctx, `User requesting Roll and is authorized`)
if (!newElectionRoll) {
Logger.error(ctx, "Failed to update ElectionRoll");
Expand All @@ -71,8 +72,8 @@ export async function getOrCreateElectionRoll(req: IRequest, election: Election,
Logger.error(req, "Multiple election roll entries found", electionRollEntries);
throw new InternalServerError('Multiple election roll entries found');
}
if (election.settings.voter_authentication.ip_address && electionRollEntries[0].ip_address) {
if (electionRollEntries[0].ip_address !== ip_address) {
if (election.settings.voter_authentication.ip_address && electionRollEntries[0].ip_hash) {
if (electionRollEntries[0].ip_hash !== ip_hash) {
Logger.error(req, "IP Address does not match saved voter roll", electionRollEntries);
throw new Unauthorized('IP Address does not match saved voter roll');
}
Expand Down
47 changes: 47 additions & 0 deletions backend/src/Migrations/2024_01_27_Create_Date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Kysely } from 'kysely'

export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('electionDB')
.addColumn('claim_key_hash', 'varchar')
.addColumn('is_public', 'boolean')
.addColumn('create_date', 'varchar')
.execute()

await db.updateTable('electionDB')
.set({ create_date: new Date().toISOString() })
.execute()

await db.schema.alterTable('electionDB')
.alterColumn('create_date', (col) => col.setNotNull())
.execute()

await db.schema.alterTable('electionRollDB')
.dropColumn('ip_address')
.addColumn('ip_hash', 'varchar')
.execute()


await db.schema.alterTable('ballotDB')
.dropColumn('ip_address')
.addColumn('ip_hash', 'varchar')
.execute()
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('electionDB')
.dropColumn('claim_key_hash')
.dropColumn('is_public')
.dropColumn('create_date')
.execute()

await db.schema.alterTable('electionRollDB')
.dropColumn('ip_hash')
.addColumn('ip_address', 'varchar')
.execute()


await db.schema.alterTable('ballotDB')
.dropColumn('ip_hash')
.addColumn('ip_address', 'varchar')
.execute()
}
11 changes: 11 additions & 0 deletions backend/src/Migrators/migrate-down.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require('dotenv').config()
import { createMigrator, handleMigration } from "./migration-utils"

async function migrateDown() {
const { migrator, db } = createMigrator()
handleMigration(await migrator.migrateDown())

await db.destroy()
}

migrateDown()
11 changes: 11 additions & 0 deletions backend/src/Migrators/migrate-to-latest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require('dotenv').config()
import { createMigrator, handleMigration } from "./migration-utils"

async function migrateToLatest() {
const { migrator, db } = createMigrator()
handleMigration(await migrator.migrateToLatest())

await db.destroy()
}

migrateToLatest()
11 changes: 11 additions & 0 deletions backend/src/Migrators/migrate-up.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require('dotenv').config()
import { createMigrator, handleMigration } from "./migration-utils"

async function migrateUp() {
const { migrator, db } = createMigrator()
handleMigration(await migrator.migrateUp())

await db.destroy()
}

migrateUp()
42 changes: 42 additions & 0 deletions backend/src/Migrators/migration-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as path from 'path'
import { promises as fs } from 'fs'
import {
Migrator,
FileMigrationProvider,
MigrationResultSet,
} from 'kysely'

import servicelocator from '../ServiceLocator'

export function createMigrator() {
const db = servicelocator.database()
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
// This needs to be an absolute path.
migrationFolder: path.join(__dirname, '../Migrations'),
}),
})
return {
migrator,
db
}
}

export async function handleMigration(migrationResult: MigrationResultSet) {
migrationResult.results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration "${it.migrationName}" was executed successfully`)
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})

if (migrationResult.error) {
console.error('failed to migrate')
console.error(migrationResult.error)
process.exit(1)
}
}
3 changes: 1 addition & 2 deletions backend/src/Models/CastVoteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,13 @@ export default class CastVoteStore {
ballot.user_id,
ballot.status,
ballot.date_submitted,
ballot.ip_address,
JSON.stringify(ballot.votes),
JSON.stringify(ballot.history),
ballot.precinct,
];

const ballotSQL = pgFormat(
`INSERT INTO ${this._ballotTableName} (ballot_id,election_id,user_id,status,date_submitted,ip_address,votes,history,precinct)
`INSERT INTO ${this._ballotTableName} (ballot_id,election_id,user_id,status,date_submitted,ip_hash,votes,history,precinct)
VALUES (%L);`,
ballotValues
);
Expand Down
6 changes: 3 additions & 3 deletions backend/src/Models/ElectionRolls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default class ElectionRollDB implements IElectionRollStore {
}))
}

getElectionRoll(election_id: string, voter_id: string | null, email: string | null, ip_address: string | null, ctx: ILoggingContext): Promise<ElectionRoll[] | null> {
getElectionRoll(election_id: string, voter_id: string | null, email: string | null, ip_hash: string | null, ctx: ILoggingContext): Promise<ElectionRoll[] | null> {
Logger.debug(ctx, `${tableName}.get election:${election_id}, voter:${voter_id}`);

return this._postgresClient
Expand All @@ -121,8 +121,8 @@ export default class ElectionRollDB implements IElectionRollStore {
ors.push(eb.cmpr('email', '=', email))
}

if (ip_address) {
ors.push(eb.cmpr('ip_address', '=', ip_address))
if (ip_hash) {
ors.push(eb.cmpr('ip_hash', '=', ip_hash))
}

return eb.or(ors)
Expand Down
1 change: 0 additions & 1 deletion backend/src/Models/Elections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export default class ElectionsDB {

createElection(election: Election, ctx: ILoggingContext, reason: string): Promise<Election> {
Logger.debug(ctx, `${tableName}.createElection`, election);

const newElection = this._postgresClient
.insertInto(tableName)
.values(election)
Expand Down
2 changes: 1 addition & 1 deletion backend/src/Models/IElectionRollStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface IElectionRollStore {
election_id: string,
voter_id: string|null,
email: string|null,
ip_address: string|null,
ip_hash: string|null,
ctx:ILoggingContext
) => Promise<ElectionRoll[] | null>;
update: (
Expand Down
6 changes: 3 additions & 3 deletions backend/src/Models/__mocks__/ElectionRolls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ export default class ElectionRollDB implements IElectionRollStore{
return Promise.resolve(res)
}

getElectionRoll(election_id: string, voter_id: string|null, email: string|null, ip_address: string|null, ctx:ILoggingContext): Promise<[ElectionRoll] | null> {
Logger.debug(ctx, `MockElectionRolls get election:${election_id}, voter_id:${voter_id}, email: ${email}, ip_address: ${ip_address}`);
getElectionRoll(election_id: string, voter_id: string|null, email: string|null, ip_hash: string|null, ctx:ILoggingContext): Promise<[ElectionRoll] | null> {
Logger.debug(ctx, `MockElectionRolls get election:${election_id}, voter_id:${voter_id}, email: ${email}, ip_hash: ${ip_hash}`);
let roll = this._electionRolls.filter(electionRolls => {
if (electionRolls.election_id!==election_id) return false
if (ip_address && electionRolls.ip_address===ip_address) return true
if (ip_hash && electionRolls.ip_hash===ip_hash) return true
if (voter_id && electionRolls.voter_id ===voter_id) return true
if (email && electionRolls.email === email) return true
return false
Expand Down
46 changes: 0 additions & 46 deletions backend/src/migrate-to-latest.ts

This file was deleted.

2 changes: 1 addition & 1 deletion domain_model/Ballot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface Ballot {
user_id?: Uid; //ID of user who cast ballot TODO: replace with voter ID
status: string; //Status of string (saved, submitted)
date_submitted: number; //time ballot was submitted, represented as unix timestamp (Date.now())
ip_address?: string; // ip address if once_per_ip is enabled
ip_hash?: string; // ip address if once_per_ip is enabled
votes: Vote[]; // One per poll
history?: BallotAction[];
precinct?: string; // Precint of voter
Expand Down
6 changes: 6 additions & 0 deletions domain_model/Election.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ export interface Election {
races: Race[]; // one or more race definitions
settings: ElectionSettings;
auth_key?: string;
claim_key_hash?: string;
is_public?: Boolean;
create_date: Date | string;
}
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

export interface NewElection extends PartialBy<Election,'election_id'|'create_date'> {}

export function electionValidation(obj:Election): string | null {
if (!obj){
Expand Down
2 changes: 1 addition & 1 deletion domain_model/ElectionRoll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface ElectionRoll {
email?: string; // Email address of voter
submitted: boolean; //has ballot been submitted
ballot_id?: Uid; //ID of ballot, unsure if this is needed
ip_address?: string; //IP Address of voter
ip_hash?: string; //IP Address of voter
address?: string; // Address of voter
state: ElectionRollState; //state of election roll
history?: ElectionRollAction[];// history of changes to election roll
Expand Down
Loading

0 comments on commit 9c2e452

Please sign in to comment.