feat: add member portal and auth hardening
This commit is contained in:
445
scripts/deployment/bootstrap-portal-auth.cjs
Normal file
445
scripts/deployment/bootstrap-portal-auth.cjs
Normal file
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEFAULT_ROLES = [
|
||||
{
|
||||
roleName: 'DBIS_Super_Admin',
|
||||
roleDescription: 'DBIS Super Admin - Full network control',
|
||||
accessLevel: 'tier_1',
|
||||
permissions: ['all'],
|
||||
},
|
||||
{
|
||||
roleName: 'DBIS_Ops',
|
||||
roleDescription: 'DBIS Operations - Network monitoring and operations',
|
||||
accessLevel: 'tier_2',
|
||||
permissions: ['network_operations', 'gas_qps_control', 'incident_management'],
|
||||
},
|
||||
{
|
||||
roleName: 'DBIS_Risk',
|
||||
roleDescription: 'DBIS Risk Officer - Risk monitoring and compliance',
|
||||
accessLevel: 'tier_2',
|
||||
permissions: ['risk_monitoring', 'compliance_audit', 'stress_testing'],
|
||||
},
|
||||
{
|
||||
roleName: 'SCB_Admin',
|
||||
roleDescription: 'SCB Admin - Full control over jurisdiction',
|
||||
accessLevel: 'tier_3',
|
||||
permissions: ['scb_administration', 'fi_management', 'corridor_management'],
|
||||
},
|
||||
{
|
||||
roleName: 'SCB_Risk',
|
||||
roleDescription: 'SCB Risk Officer - Local risk monitoring',
|
||||
accessLevel: 'tier_3',
|
||||
permissions: ['local_risk_monitoring', 'compliance_review'],
|
||||
},
|
||||
{
|
||||
roleName: 'SCB_Tech',
|
||||
roleDescription: 'SCB Tech/API Owner - Technical operations',
|
||||
accessLevel: 'tier_3',
|
||||
permissions: ['api_management', 'technical_operations'],
|
||||
},
|
||||
{
|
||||
roleName: 'SCB_Read_Only',
|
||||
roleDescription: 'SCB Read-Only - View-only access',
|
||||
accessLevel: 'tier_4',
|
||||
permissions: ['view_only'],
|
||||
},
|
||||
];
|
||||
|
||||
function getBootstrapEmployee() {
|
||||
return {
|
||||
employeeId: process.env.DBIS_BOOTSTRAP_EMPLOYEE_ID || 'dbis-core-ops-001',
|
||||
employeeName: process.env.DBIS_BOOTSTRAP_EMPLOYEE_NAME || 'Core Operations User',
|
||||
email: process.env.DBIS_BOOTSTRAP_EMPLOYEE_EMAIL || 'core.ops@d-bis.org',
|
||||
securityClearance: process.env.DBIS_BOOTSTRAP_EMPLOYEE_CLEARANCE || 'tier_1',
|
||||
roleName: process.env.DBIS_BOOTSTRAP_EMPLOYEE_ROLE || 'DBIS_Super_Admin',
|
||||
};
|
||||
}
|
||||
|
||||
function shouldBootstrapEmployee() {
|
||||
return Boolean(
|
||||
process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH?.trim() ||
|
||||
process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD?.trim() ||
|
||||
process.env.DBIS_PORTAL_SHARED_SECRET?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function getBootstrapMember() {
|
||||
return {
|
||||
memberId: process.env.DBIS_BOOTSTRAP_MEMBER_ID || 'member.test',
|
||||
memberName: process.env.DBIS_BOOTSTRAP_MEMBER_NAME || 'DBIS Member Test User',
|
||||
email: process.env.DBIS_BOOTSTRAP_MEMBER_EMAIL || 'member.test@members.d-bis.org',
|
||||
sovereignBankId: process.env.DBIS_BOOTSTRAP_MEMBER_SOVEREIGN_BANK_ID || null,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldBootstrapMember() {
|
||||
return Boolean(
|
||||
process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH?.trim() ||
|
||||
process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD?.trim() ||
|
||||
process.env.DBIS_MEMBER_PORTAL_SHARED_SECRET?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function getBootstrapPasswordHash() {
|
||||
const explicitHash = process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH?.trim();
|
||||
if (explicitHash) {
|
||||
return explicitHash;
|
||||
}
|
||||
|
||||
const explicitPassword = process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD?.trim();
|
||||
if (explicitPassword) {
|
||||
return bcrypt.hashSync(explicitPassword, 12);
|
||||
}
|
||||
|
||||
const legacyBootstrapSecret = process.env.DBIS_PORTAL_SHARED_SECRET?.trim();
|
||||
if (legacyBootstrapSecret) {
|
||||
return bcrypt.hashSync(legacyBootstrapSecret, 12);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Set DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH or DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD before bootstrapping portal auth'
|
||||
);
|
||||
}
|
||||
|
||||
function getBootstrapMemberPasswordHash() {
|
||||
const explicitHash = process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH?.trim();
|
||||
if (explicitHash) {
|
||||
return explicitHash;
|
||||
}
|
||||
|
||||
const explicitPassword = process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD?.trim();
|
||||
if (explicitPassword) {
|
||||
return bcrypt.hashSync(explicitPassword, 12);
|
||||
}
|
||||
|
||||
const legacyMemberSecret = process.env.DBIS_MEMBER_PORTAL_SHARED_SECRET?.trim();
|
||||
if (legacyMemberSecret) {
|
||||
return bcrypt.hashSync(legacyMemberSecret, 12);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Set DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH or DBIS_BOOTSTRAP_MEMBER_PASSWORD before bootstrapping member portal auth'
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS dbis_roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
"roleId" TEXT UNIQUE NOT NULL,
|
||||
"roleName" TEXT NOT NULL,
|
||||
"roleDescription" TEXT NOT NULL,
|
||||
"accessLevel" TEXT NOT NULL,
|
||||
permissions JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL
|
||||
)
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS dbis_roles_access_level_idx ON dbis_roles ("accessLevel")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS dbis_roles_role_id_idx ON dbis_roles ("roleId")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS dbis_roles_role_name_idx ON dbis_roles ("roleName")`
|
||||
);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS employee_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
"employeeId" TEXT UNIQUE NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"employeeName" TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
"securityClearance" TEXT NOT NULL,
|
||||
"portalPasswordHash" TEXT,
|
||||
"cryptographicBadgeId" TEXT,
|
||||
"hsmCredentialId" TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
"issuedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"expiresAt" TIMESTAMPTZ,
|
||||
"revokedAt" TIMESTAMPTZ,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT employee_credentials_role_fk
|
||||
FOREIGN KEY ("roleId") REFERENCES dbis_roles(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS employee_credentials_employee_id_idx ON employee_credentials ("employeeId")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "portalPasswordHash" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mustRotatePassword" BOOLEAN NOT NULL DEFAULT FALSE`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "lockedUntil" TIMESTAMPTZ`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMPTZ`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordChangedAt" TIMESTAMPTZ`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordResetTokenHash" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordResetTokenExpiresAt" TIMESTAMPTZ`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaEnabled" BOOLEAN NOT NULL DEFAULT FALSE`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretCiphertext" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretIv" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretTag" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaEnrolledAt" TIMESTAMPTZ`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS employee_credentials_role_id_idx ON employee_credentials ("roleId")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS employee_credentials_security_clearance_idx ON employee_credentials ("securityClearance")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS employee_credentials_status_idx ON employee_credentials (status)`
|
||||
);
|
||||
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS portal_member_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
"memberId" TEXT UNIQUE NOT NULL,
|
||||
"memberName" TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
"institutionName" TEXT,
|
||||
"institutionCountry" TEXT,
|
||||
"participantId" TEXT,
|
||||
lei TEXT,
|
||||
"sovereignBankId" TEXT,
|
||||
"portalPasswordHash" TEXT NOT NULL,
|
||||
"approvalStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||
"approvedAt" TIMESTAMPTZ,
|
||||
"approvedByEmployeeId" TEXT,
|
||||
"mustRotatePassword" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lockedUntil" TIMESTAMPTZ,
|
||||
"lastLoginAt" TIMESTAMPTZ,
|
||||
"passwordChangedAt" TIMESTAMPTZ,
|
||||
"passwordResetTokenHash" TEXT,
|
||||
"passwordResetTokenExpiresAt" TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
"issuedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"expiresAt" TIMESTAMPTZ,
|
||||
"revokedAt" TIMESTAMPTZ,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL
|
||||
)
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE portal_member_accounts ADD COLUMN IF NOT EXISTS "institutionName" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE portal_member_accounts ADD COLUMN IF NOT EXISTS "institutionCountry" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_member_id_idx ON portal_member_accounts ("memberId")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_email_idx ON portal_member_accounts (email)`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_institution_country_idx ON portal_member_accounts ("institutionCountry")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_participant_id_idx ON portal_member_accounts ("participantId")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_lei_idx ON portal_member_accounts (lei)`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_approval_status_idx ON portal_member_accounts ("approvalStatus")`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_status_idx ON portal_member_accounts (status)`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE INDEX IF NOT EXISTS portal_member_accounts_sovereign_bank_id_idx ON portal_member_accounts ("sovereignBankId")`
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertRoles() {
|
||||
for (const role of DEFAULT_ROLES) {
|
||||
const existing = await prisma.dbis_roles.findFirst({
|
||||
where: { roleName: role.roleName },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.dbis_roles.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
roleDescription: role.roleDescription,
|
||||
accessLevel: role.accessLevel,
|
||||
permissions: role.permissions,
|
||||
status: 'active',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.dbis_roles.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
roleId: uuidv4(),
|
||||
roleName: role.roleName,
|
||||
roleDescription: role.roleDescription,
|
||||
accessLevel: role.accessLevel,
|
||||
permissions: role.permissions,
|
||||
status: 'active',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertBootstrapEmployee() {
|
||||
const employee = getBootstrapEmployee();
|
||||
const portalPasswordHash = getBootstrapPasswordHash();
|
||||
const role = await prisma.dbis_roles.findFirst({
|
||||
where: { roleName: employee.roleName },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new Error(`Role ${employee.roleName} was not created`);
|
||||
}
|
||||
|
||||
await prisma.employee_credentials.upsert({
|
||||
where: { employeeId: employee.employeeId },
|
||||
update: {
|
||||
roleId: role.id,
|
||||
employeeName: employee.employeeName,
|
||||
email: employee.email,
|
||||
securityClearance: employee.securityClearance,
|
||||
portalPasswordHash,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
employeeId: employee.employeeId,
|
||||
roleId: role.id,
|
||||
employeeName: employee.employeeName,
|
||||
email: employee.email,
|
||||
securityClearance: employee.securityClearance,
|
||||
portalPasswordHash,
|
||||
status: 'active',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
async function upsertBootstrapMember() {
|
||||
const member = getBootstrapMember();
|
||||
const portalPasswordHash = getBootstrapMemberPasswordHash();
|
||||
|
||||
const existing = await prisma.portal_member_accounts.findFirst({
|
||||
where: {
|
||||
OR: [{ memberId: member.memberId }, { email: member.email }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.portal_member_accounts.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
memberId: member.memberId,
|
||||
memberName: member.memberName,
|
||||
email: member.email,
|
||||
sovereignBankId: member.sovereignBankId,
|
||||
portalPasswordHash,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return member;
|
||||
}
|
||||
|
||||
await prisma.portal_member_accounts.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
memberId: member.memberId,
|
||||
memberName: member.memberName,
|
||||
email: member.email,
|
||||
sovereignBankId: member.sovereignBankId,
|
||||
portalPasswordHash,
|
||||
status: 'active',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureSchema();
|
||||
await upsertRoles();
|
||||
const employee = shouldBootstrapEmployee() ? await upsertBootstrapEmployee() : null;
|
||||
const member = shouldBootstrapMember() ? await upsertBootstrapMember() : null;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
bootstrapEmployee: employee
|
||||
? {
|
||||
employeeId: employee.employeeId,
|
||||
email: employee.email,
|
||||
employeeName: employee.employeeName,
|
||||
roleName: employee.roleName,
|
||||
}
|
||||
: null,
|
||||
bootstrapMember: member
|
||||
? {
|
||||
memberId: member.memberId,
|
||||
email: member.email,
|
||||
memberName: member.memberName,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (error) => {
|
||||
console.error(error.stack || error.message || String(error));
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -145,7 +145,7 @@ create_frontend_container() {
|
||||
|
||||
# Create environment file for frontend
|
||||
log_info "Creating frontend environment configuration..."
|
||||
local api_url="http://${DBIS_API_PRIMARY_IP:-192.168.11.150}:${DBIS_API_PORT:-3000}"
|
||||
local api_url="http://${DBIS_API_PRIMARY_IP:-192.168.11.155}:${DBIS_API_PORT:-3000}"
|
||||
|
||||
pct exec "$vmid" -- bash -c "cat > ${DBIS_CORE_PROJECT_ROOT:-/opt/dbis-core}/frontend/.env <<EOF
|
||||
VITE_API_BASE_URL=${api_url}
|
||||
@@ -245,4 +245,3 @@ log_info "Next steps:"
|
||||
log_info "1. Check service status: ./scripts/management/status.sh"
|
||||
log_info "2. Run database migrations: ./scripts/deployment/configure-database.sh"
|
||||
log_info "3. Test API health: curl http://${DBIS_API_PRIMARY_IP:-192.168.11.150}:${DBIS_API_PORT:-3000}/health"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user