Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Election creation dates, IP hashes, migration scripts #456

Merged
merged 9 commits into from
Jan 30, 2024
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
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.

10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,15 @@ services:
- POSTGRES_PASSWORD=ChangeMeOrDontTest2020
restart:
on-failure
keycloak:
image: quay.io/keycloak/keycloak:23.0.1
command:
- start-dev
- --import-realm
ports:
- '8080:8080'
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
networks:
star-net: {}
Loading
Loading