Security Overview
Security Architecture
The Lofty Golden Oil ERP system implements a defense-in-depth strategy with four distinct security layers:
┌─────────────────────────────────────────────┐
│ Network Layer │
│ Oracle Cloud Security Lists / SELinux │
├─────────────────────────────────────────────┤
│ Transport Layer │
│ TLS 1.2+ / Let's Encrypt / HSTS │
├─────────────────────────────────────────────┤
│ Application Layer │
│ JWT Auth / RBAC / Input Validation / CORS │
├─────────────────────────────────────────────┤
│ Data Layer │
│ bcrypt Hashing / Parameterized Queries │
│ Encrypted Secrets / Audit Logging │
└─────────────────────────────────────────────┘
Each layer operates independently. A failure in one layer is mitigated by the layers above and below it.
Authentication System
JWT Bearer Tokens
| Property | Value |
|---|---|
| Algorithm | HS256 (HMAC-SHA256) |
| Expiry | 24 hours |
| Token Storage | Client-side (localStorage or memory) |
| Refresh Tokens | Not implemented — re-login required |
Flow:
- User submits email and password to
POST /api/auth/login. - Server validates credentials against
userstable. - Server generates a signed JWT containing:
userId,role,email. - JWT is returned to the client.
- Client includes the JWT in the
Authorization: Bearer <token>header on all subsequent requests. - Server middleware validates the JWT signature and expiry on every protected route.
Security Considerations:
- The JWT secret must be stored in an environment variable, never hardcoded.
- The secret should be a cryptographically strong random string (minimum 256 bits).
- The secret should be rotated periodically; upon rotation, all active tokens are invalidated.
- No refresh token mechanism exists — once expired, users must re-authenticate.
Password Hashing
| Property | Value |
|---|---|
| Algorithm | bcrypt (via bcryptjs) |
| Salt Rounds | 12 (default) |
| Hash Length | 60 characters |
Implementation:
- Passwords are never stored in plaintext.
- Every password is hashed with a unique salt before storage.
- Verification compares the provided password against the stored hash using
bcrypt.compare(). - The
password_hashcolumn isVARCHAR(255)to accommodate future algorithm changes.
Password Policy Recommendations:
- Minimum 8 characters
- At least one uppercase letter, one number, and one special character
- No reuse of last 5 passwords
- Account lockout after 5 consecutive failed attempts (recommended — not yet implemented)
Authorization
Role-Based Access Control (RBAC)
Four roles are defined in the system:
| Role | Description |
|---|---|
admin | Full system access — all modules, user management, settings, audit logs |
production | Inventory and production management, read-only for customers/suppliers |
sales | Customer management, sales, invoicing, payments, read-only for inventory/suppliers |
accountant | Expense management, full accounting module (GL, vouchers, journals, reports) |
Enforcement is two-tiered:
- Server-side middleware: Each API route has role-based middleware that rejects unauthorized requests with HTTP 403. This is the authoritative access control mechanism.
- Client-side rendering: React components conditionally render navigation and buttons based on the user's role. This is UX-only — it provides a clean interface but is not a security mechanism. All security enforcement happens server-side.
Transport Security
TLS 1.2+ via Let's Encrypt
| Property | Value |
|---|---|
| Certificate Authority | Let's Encrypt (via certbot) |
| Domain | loftygoldenoil.com |
| Auto-Renewal | Yes (certbot cron job) |
| Protocol | TLS 1.2 and 1.3 |
| Ciphers | Modern cipher suites only |
HSTS (HTTP Strict Transport Security)
Nginx is configured to send HSTS headers, instructing browsers to only communicate with the server over HTTPS:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Nginx SSL Configuration
server {
listen 443 ssl http2;
server_name loftygoldenoil.com;
ssl_certificate /etc/letsencrypt/live/loftygoldenoil.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/loftygoldenoil.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
}
Network Security
Oracle Cloud Security Lists
| Direction | Protocol | Port | Source/Destination |
|---|---|---|---|
| Inbound | TCP | 22 | Source: All (SSH access) |
| Inbound | TCP | 80 | Source: All (HTTP) |
| Inbound | TCP | 443 | Source: All (HTTPS) |
| Inbound | TCP | 8443 | Source: All (Staging) |
| Outbound | All | All | Destination: All |
Hardening Recommendations:
- Restrict SSH (port 22) access to known IP addresses only.
- Consider adding a VPN or bastion host for SSH access.
- Only ports 80, 443, and 8443 are accessible from the internet — Node.js (port 4000) is not directly exposed.
SELinux
Security-Enhanced Linux is enforcing on the Oracle Linux 9 host with the following boolean permissions:
| Boolean | Value | Purpose |
|---|---|---|
httpd_can_network_connect | on | Allows Nginx to make outbound network connections (e.g., for API proxying) |
httpd_can_network_connect_db | on | Allows Nginx to connect to database ports |
Application Security
CORS Configuration
app.use(cors()); // Currently fully open
Current State: CORS is configured to accept requests from any origin. This is acceptable during development but must be restricted in production.
Recommended Production Configuration:
app.use(cors({
origin: [
'https://loftygoldenoil.com',
'http://170.9.14.60:8443'
],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
Body Size Limit
| Property | Value |
|---|---|
| Maximum Request Body | 15 MB |
| Configured In | Express middleware (express.json({ limit: '15mb' })) |
Prevents denial-of-service attacks via oversized payloads.
SQL Injection Prevention
All database queries use parameterized queries (prepared statements) via the MySQL2 library. User input is never concatenated directly into SQL strings.
// Safe — parameterized query
const [rows] = await pool.execute(
'SELECT * FROM users WHERE email = ? AND status = ?',
[email, 'active']
);
// Unsafe — NEVER done in this codebase
const query = `SELECT * FROM users WHERE email = '${email}'`;
XSS Prevention
- React automatically escapes all rendered values, preventing XSS in the frontend.
- API responses return
Content-Type: application/json— browsers do not execute JSON as HTML. - No user-generated HTML is rendered without sanitization.
Data Security
Password Storage
- Passwords are hashed using bcrypt with 12 salt rounds before storage.
- The
password_hashcolumn is never exposed in API responses. - The login endpoint does not reveal whether an email exists in the system (returns a generic error message).
Secret Management
| Secret | Storage Method |
|---|---|
| Database password | Environment variable |
| JWT signing secret | Environment variable |
| Resend API key | Environment variable |
| SSH private key | Filesystem (restricted permissions) |
| PM2 ecosystem config | Filesystem (restricted permissions) |
Rules:
- Secrets are never committed to source control.
- Environment variables are used for all sensitive configuration.
- The
.envfile is listed in.gitignore. - Deploy keys are stored in
deploy/directory withchmod 600permissions.
Database Credentials
| Property | Value |
|---|---|
| Username | lgo |
| Password | Stored in environment variable (not in code) |
| Host | localhost (database only accessible from the VPS itself) |
| Permissions | Full access to lgo_erp_prod and lgo_erp_staging only |
Infrastructure Security
SSH Key Authentication
| Property | Value |
|---|---|
| Authentication Method | SSH key only |
| Password Authentication | Disabled |
| Key Type | RSA or ED25519 |
| Key Location | /Users/fuhsi/Documents/helpdesk/lofty-golden-oil-erp/ssh-key-2026-07-25.key |
| Deploy Key | deploy/oracle_key (private), deploy/vps_ssh_key.txt (copy) |
| VPS User | opc |
Rules:
- SSH private keys must have
chmod 600permissions. - Never share or transmit private keys over insecure channels.
- Rotate SSH keys periodically.
PM2 Process Isolation
| Property | Value |
|---|---|
| Config Path | /opt/lgo-erp/ecosystem.config.cjs |
| Production Process | lgo-prod (port 4000, database lgo_erp_prod) |
| Staging Process | lgo-staging (port 8443, database lgo_erp_staging) |
| Run Mode | fork (single instance per environment) |
| Restart Policy | Automatic restart on crash |
PM2 runs each environment as a separate OS-level process with its own memory space, preventing one environment from affecting the other.
Nginx as Reverse Proxy
Internet → Nginx (443) → Node.js (4000)
- Node.js runs on port 4000 and is not directly accessible from the internet.
- Nginx handles SSL termination, static file serving, and request proxying.
- Nginx serves the React frontend as static files from
/opt/lgo-erp/dist/. - API requests (
/api/*) are proxied to Node.js on port 4000. - SPA fallback via
try_files $uri /index.htmlensures client-side routing works.
Audit Trail
audit_log Table
Every significant system action is recorded in the audit_log table:
| Column | Purpose |
|---|---|
user_id | Which user performed the action |
action | What was done (CREATE, UPDATE, DELETE, LOGIN, APPROVE, REJECT) |
entity_type | Which entity was affected (sale, invoice, expense, etc.) |
entity_id | ID of the specific record |
details | Human-readable description of the change |
ip_address | Client IP address (supports IPv6) |
created_at | Timestamp of the action |
Key Properties:
- Audit logs are append-only — they are never deleted or modified.
- The
audit_logtable has no DELETE endpoint. - All CRUD operations, login events, and approval actions are logged.
- IP addresses are captured for security forensics.
Security Best Practices
For System Administrators
| Practice | Frequency |
|---|---|
| Review audit logs for anomalies | Daily |
| Verify SSL certificate renewal | Monthly |
| Check for OS security updates | Weekly |
| Rotate SSH keys | Quarterly |
| Review user access and remove inactive accounts | Monthly |
| Backup database and verify restoration | Daily (automated) + Monthly (manual test) |
| Rotate JWT secret | Quarterly |
| Review Nginx access logs for suspicious activity | Weekly |
For Developers
| Practice | Description |
|---|---|
| Never commit secrets | Use environment variables for all sensitive data |
| Use parameterized queries | Always use prepared statements — never concatenate user input into SQL |
| Validate all input | Validate and sanitize all user input on the server side |
| Use HTTPS | All communication must be encrypted in transit |
| Follow the principle of least privilege | Grant only the minimum permissions needed |
| Keep dependencies updated | Run npm audit regularly and update vulnerable packages |
| Review error messages | Ensure error messages do not leak sensitive information |
For End Users
| Practice | Description |
|---|---|
| Use strong passwords | Minimum 8 characters, mix of letters, numbers, and symbols |
| Do not share credentials | Each user should have their own account |
| Log out when finished | Especially on shared computers |
| Report suspicious activity | Notify IT immediately if something seems wrong |
Known Security Considerations
Current Gaps
| Issue | Severity | Recommendation |
|---|---|---|
| CORS is fully open | High | Restrict to production and staging domains |
| No rate limiting | High | Implement rate limiting on auth endpoints and API |
| No account lockout | Medium | Add lockout after failed login attempts |
| SSH port 22 open to all | Medium | Restrict to known IP addresses |
| No refresh token rotation | Medium | Implement refresh tokens for better session management |
| No Content Security Policy | Medium | Add CSP headers via Nginx |
| No HTTP security headers | Low | Add X-Frame-Options, X-Content-Type-Options, etc. |
| No input length validation | Low | Add server-side validation for field lengths |
Remediation Priority
- Immediate: Restrict CORS origins
- Within 1 week: Implement rate limiting on login and API endpoints
- Within 1 month: Add HTTP security headers, CSP, account lockout
- Within 3 months: Implement refresh tokens, restrict SSH access
Compliance Considerations
Nigerian Data Protection Regulation (NDPR) 2019
- Personal data of customers, suppliers, and employees must be collected and processed lawfully.
- Data subjects have the right to access, correct, and request deletion of their data.
- A Data Protection Officer should be appointed if processing personal data at scale.
- Cross-border data transfers must comply with NDPR requirements.
PCI-DSS Awareness
- The ERP system records payment methods and reference numbers for reconciliation.
- No credit card numbers or payment credentials are stored in the system.
- All payment processing occurs through external providers (bank transfers, cash).
- If payment card processing is added in the future, PCI-DSS compliance must be assessed.
Financial Record Retention
- Nigerian tax law requires retention of financial records for a minimum of 7 years.
- The audit log should be retained indefinitely for compliance and forensics.
- Database backups should be retained for at least 12 months.
Data Protection Principles
| Principle | Implementation |
|---|---|
| Lawfulness | Data collected for legitimate business purposes |
| Purpose Limitation | Data used only for ERP business functions |
| Data Minimization | Only necessary data is collected |
| Accuracy | Users can update their own records; admin manages others |
| Storage Limitation | Retention policies defined per data category |
| Integrity & Confidentiality | bcrypt passwords, parameterized queries, audit logging |
| Accountability | Audit trail tracks all data access and modifications |