diff --git a/GDPR_COMPLIANCE_GUIDE.md b/GDPR_COMPLIANCE_GUIDE.md new file mode 100644 index 0000000000..3284ef3732 --- /dev/null +++ b/GDPR_COMPLIANCE_GUIDE.md @@ -0,0 +1,877 @@ +# GDPR Compliance Guide for Parse Server + +## Overview + +**Parse Server provides audit logging infrastructure. Everything else is your responsibility.** + +This guide clarifies: +1. What Parse Server provides +2. What you (the developer) must implement +3. What your organization must ensure + +## Part 1: What Parse Server Provides ✅ + +### Audit Logging (Infrastructure Level) + +Parse Server includes a comprehensive audit logging system that tracks: + +- **User Authentication** - Login attempts (successful and failed) +- **Data Access** - All read operations on Parse objects +- **Data Modifications** - Create, update, and delete operations +- **ACL Changes** - Access control list modifications +- **Schema Changes** - Schema creation, modification, and deletion +- **Push Notifications** - Push notification sends + +**Key Features:** +- Automatic, transparent logging at the database level +- Structured JSON format for easy parsing +- Daily log rotation with configurable retention +- Automatic masking of sensitive data (passwords, session tokens) +- IP address tracking +- Configurable via code or environment variables + +**What This Means:** +- You get automatic compliance with GDPR Article 30 (Records of Processing Activities) +- You have an audit trail for Article 33 (Data Breach Notification) +- You have evidence for Article 32 (Security of Processing) + +**Configuration:** +```javascript +new ParseServer({ + // ... other options + auditLog: { + auditLogFolder: './audit-logs', // Required to enable + datePattern: 'YYYY-MM-DD', // Optional (default: daily rotation) + maxSize: '20m', // Optional (default: 20MB per file) + maxFiles: '14d', // Optional (default: 14 days retention) + } +}); +``` + +### That's It + +Parse Server provides **only** audit logging because: +- It's framework-level infrastructure +- Requires deep integration with database operations +- Must be automatic and transparent +- Developers cannot reliably implement it themselves + +Everything else is application-specific and must be implemented by you. + +--- + +## Part 2: What You Must Implement 👨‍💻 + +GDPR compliance requires application-specific features that **you must build** using standard Parse Server APIs. + +### 1. Right to Access (Article 15) - Data Export + +**What:** Users must be able to request a copy of all their personal data. + +**Your Responsibility:** +- Know your data model and relationships +- Aggregate all user data across all classes +- Format the export (JSON, CSV, PDF, etc.) +- Deliver to the user + +**Implementation Example (Cloud Code):** + +```javascript +// Cloud Code function to export user data +Parse.Cloud.define('exportMyData', async (request) => { + const exportData = { + exportDate: new Date().toISOString(), + user: request.user.toJSON(), + relatedData: {} + }; + + // Query all classes in parallel for better performance + const [orders, reviews, comments] = await Promise.all([ + new Parse.Query('Order') + .equalTo('user', request.user) + .find({ useMasterKey: true }), + + new Parse.Query('Review') + .equalTo('author', request.user) + .find({ useMasterKey: true }), + + new Parse.Query('Comment') + .equalTo('author', request.user) + .find({ useMasterKey: true }), + + // Add more queries as needed for your application + ]); + + exportData.relatedData.orders = orders.map(o => o.toJSON()); + exportData.relatedData.reviews = reviews.map(r => r.toJSON()); + exportData.relatedData.comments = comments.map(c => c.toJSON()); + + // Include audit logs for this user + // (You'll need to query your audit log files) + + return exportData; +}, { + requireUser: true +}); +``` + +**Response Time:** Must respond within 30 days (Article 12). + +--- + +### 2. Right to Erasure / "Right to be Forgotten" (Article 17) - Data Deletion + +**What:** Users can request deletion of their personal data. + +**Your Responsibility:** +- Implement business rules (what can/cannot be deleted) +- Handle legal retention requirements (e.g., financial records) +- Decide: delete vs. anonymize +- Handle cascading deletes or orphaned data +- Verify user identity before deletion + +**Implementation Example (Cloud Code):** + +```javascript +Parse.Cloud.define('deleteMyData', async (request) => { + // STEP 1: Check if deletion is allowed + const activeOrders = await new Parse.Query('Order') + .equalTo('user', request.user) + .equalTo('status', 'active') + .count({ useMasterKey: true }); + + if (activeOrders > 0) { + throw new Error('Cannot delete account with active orders. Please cancel or complete orders first.'); + } + + // STEP 2: Handle data based on legal requirements + + // Query all related data in parallel + const [allOrders, reviews, comments, wishlistItems] = await Promise.all([ + new Parse.Query('Order') + .equalTo('user', request.user) + .find({ useMasterKey: true }), + + new Parse.Query('Review') + .equalTo('author', request.user) + .find({ useMasterKey: true }), + + new Parse.Query('Comment') + .equalTo('author', request.user) + .find({ useMasterKey: true }), + + new Parse.Query('WishlistItem') + .equalTo('user', request.user) + .find({ useMasterKey: true }), + ]); + + // ANONYMIZE financial records (legal requirement to retain) + for (const order of allOrders) { + order.set('user', null); + order.set('userName', 'DELETED_USER'); + order.set('userEmail', 'deleted@example.com'); + order.set('userPhone', null); + await order.save(null, { useMasterKey: true }); + } + + // DELETE reviews, comments, and wishlist items in parallel + await Promise.all([ + Parse.Object.destroyAll(reviews, { useMasterKey: true }), + Parse.Object.destroyAll(comments, { useMasterKey: true }), + Parse.Object.destroyAll(wishlistItems, { useMasterKey: true }), + ]); + + // STEP 3: Delete user sessions + const sessions = await new Parse.Query('_Session') + .equalTo('user', request.user) + .find({ useMasterKey: true }); + await Parse.Object.destroyAll(sessions, { useMasterKey: true }); + + // STEP 4: Delete the user + await request.user.destroy({ useMasterKey: true }); + + // STEP 5: Log the deletion in audit logs + // (This happens automatically via Parse Server's audit logging) + + return { + success: true, + message: 'Account and personal data deleted', + retainedData: 'Order history anonymized per legal requirements' + }; +}, { + requireUser: true +}); +``` + +**Important Considerations:** +- **Verification:** Ensure the user is who they claim to be +- **Confirmation:** Require explicit confirmation (e.g., "type DELETE to confirm") +- **Irreversible:** Warn users that deletion is permanent +- **Legal Retention:** Some data must be retained (financial, tax, legal) +- **Exceptions:** You can refuse deletion if there's a legal basis (Article 17.3) + +--- + +### 3. Right to Data Portability (Article 20) - Structured Export + +**What:** Users can receive their data in a machine-readable format and transmit it to another service. + +**Your Responsibility:** +- Export data in structured format (JSON, CSV, XML) +- Include only data provided by the user or generated by their use +- Make it compatible with other systems + +**Implementation Example:** + +```javascript +Parse.Cloud.define('exportDataPortable', async (request) => { + const { format = 'json' } = request.params; // json, csv, xml + + // Export user-provided data only + const exportData = { + personalInfo: { + username: request.user.get('username'), + email: request.user.get('email'), + name: request.user.get('name'), + phone: request.user.get('phone'), + createdAt: request.user.get('createdAt'), + }, + content: { + reviews: [], + comments: [], + posts: [] + } + }; + + // User-generated content + const reviews = await new Parse.Query('Review') + .equalTo('author', request.user) + .find({ useMasterKey: true }); + exportData.content.reviews = reviews.map(r => ({ + productId: r.get('product').id, + rating: r.get('rating'), + text: r.get('text'), + createdAt: r.get('createdAt'), + })); + + // Convert to requested format + if (format === 'csv') { + return convertToCSV(exportData); + } else if (format === 'xml') { + return convertToXML(exportData); + } else { + return exportData; // JSON + } +}, { + requireUser: true +}); +``` + +--- + +### 4. Consent Management (Article 7) + +**What:** Track and manage user consent for data processing. + +**Your Responsibility:** +- Create schema for consent +- Record when consent was given +- Allow users to withdraw consent +- Check consent before processing +- Version control consent forms + +**Implementation Example:** + +**Schema:** +```javascript +// Create Consent schema (run once) +const consentSchema = new Parse.Schema('Consent'); +consentSchema.addPointer('user', '_User'); +consentSchema.addString('type'); // 'marketing', 'analytics', 'essential' +consentSchema.addBoolean('granted'); +consentSchema.addString('version'); // Version of consent form +consentSchema.addDate('grantedAt'); +consentSchema.addDate('withdrawnAt'); +consentSchema.addString('ipAddress'); +consentSchema.save(); +``` + +**Grant Consent:** +```javascript +Parse.Cloud.define('grantConsent', async (request) => { + const { consentType, version } = request.params; + + const consent = new Parse.Object('Consent'); + consent.set('user', request.user); + consent.set('type', consentType); + consent.set('granted', true); + consent.set('version', version); + consent.set('grantedAt', new Date()); + consent.set('ipAddress', request.ip); + + await consent.save(null, { useMasterKey: true }); + return { success: true }; +}, { + requireUser: true +}); +``` + +**Withdraw Consent:** +```javascript +Parse.Cloud.define('withdrawConsent', async (request) => { + const { consentType } = request.params; + + const consent = await new Parse.Query('Consent') + .equalTo('user', request.user) + .equalTo('type', consentType) + .equalTo('granted', true) + .first({ useMasterKey: true }); + + if (consent) { + consent.set('granted', false); + consent.set('withdrawnAt', new Date()); + await consent.save(null, { useMasterKey: true }); + } + + return { success: true }; +}, { + requireUser: true +}); +``` + +**Check Consent Before Processing:** +```javascript +Parse.Cloud.beforeSave('MarketingEmail', async (request) => { + const recipient = request.object.get('recipient'); + + // Check if user has consented to marketing + const consent = await new Parse.Query('Consent') + .equalTo('user', recipient) + .equalTo('type', 'marketing') + .equalTo('granted', true) + .first({ useMasterKey: true }); + + if (!consent) { + throw new Error('User has not consented to marketing emails'); + } +}); +``` + +--- + +### 5. Data Retention & Lifecycle Management + +**What:** Automatically delete or anonymize data after retention period. + +**Your Responsibility:** +- Define retention periods for each data type +- Implement scheduled cleanup jobs +- Balance GDPR (minimize retention) vs. legal requirements (retain financial data) + +**Implementation Example:** + +```javascript +// Scheduled job (runs daily) +Parse.Cloud.job('enforceDataRetention', async (request) => { + const { message } = request; + + // RETENTION POLICY 1: Delete inactive users after 2 years + const inactiveThreshold = new Date(); + inactiveThreshold.setFullYear(inactiveThreshold.getFullYear() - 2); + + const inactiveUsers = await new Parse.Query('_User') + .lessThan('lastLoginAt', inactiveThreshold) + .find({ useMasterKey: true }); + + message(`Found ${inactiveUsers.length} inactive users`); + + for (const user of inactiveUsers) { + // Use your deletion logic + try { + await Parse.Cloud.run('deleteMyData', {}, { + sessionToken: user.getSessionToken(), + useMasterKey: true + }); + message(`Deleted inactive user: ${user.id}`); + } catch (error) { + message(`Error deleting user ${user.id}: ${error.message}`); + } + } + + // RETENTION POLICY 2: Delete old sessions after 90 days + const sessionThreshold = new Date(); + sessionThreshold.setDate(sessionThreshold.getDate() - 90); + + const oldSessions = await new Parse.Query('_Session') + .lessThan('createdAt', sessionThreshold) + .find({ useMasterKey: true }); + + await Parse.Object.destroyAll(oldSessions, { useMasterKey: true }); + message(`Deleted ${oldSessions.length} old sessions`); + + // RETENTION POLICY 3: Anonymize old orders after 7 years (legal requirement) + const orderThreshold = new Date(); + orderThreshold.setFullYear(orderThreshold.getFullYear() - 7); + + const oldOrders = await new Parse.Query('Order') + .lessThan('createdAt', orderThreshold) + .find({ useMasterKey: true }); + + for (const order of oldOrders) { + order.set('userName', 'ANONYMIZED'); + order.set('userEmail', 'anonymized@example.com'); + order.set('shippingAddress', null); + order.set('billingAddress', null); + await order.save(null, { useMasterKey: true }); + } + message(`Anonymized ${oldOrders.length} old orders`); +}); +``` + +**Schedule the job:** +```javascript +// In your server initialization +const schedule = require('node-schedule'); + +// Run daily at 2 AM +schedule.scheduleJob('0 2 * * *', async () => { + await Parse.Cloud.startJob('enforceDataRetention'); +}); +``` + +--- + +### 6. Privacy Policy Management + +**What:** Track user acceptance of privacy policies and notify of changes. + +**Your Responsibility:** +- Create and maintain privacy policy +- Version control +- Track user acceptances +- Notify users of material changes + +**Implementation Example:** + +**Schema:** +```javascript +const policySchema = new Parse.Schema('PrivacyPolicyAcceptance'); +policySchema.addPointer('user', '_User'); +policySchema.addString('version'); +policySchema.addDate('acceptedAt'); +policySchema.addString('ipAddress'); +policySchema.save(); +``` + +**Track Acceptance:** +```javascript +Parse.Cloud.define('acceptPrivacyPolicy', async (request) => { + const { version } = request.params; + + const acceptance = new Parse.Object('PrivacyPolicyAcceptance'); + acceptance.set('user', request.user); + acceptance.set('version', version); + acceptance.set('acceptedAt', new Date()); + acceptance.set('ipAddress', request.ip); + + await acceptance.save(null, { useMasterKey: true }); + + // Update user's current policy version + request.user.set('currentPolicyVersion', version); + await request.user.save(null, { useMasterKey: true }); + + return { success: true }; +}, { + requireUser: true +}); +``` + +**Check if User Needs to Accept New Policy:** +```javascript +Parse.Cloud.define('checkPolicyStatus', async (request) => { + const currentVersion = '2.0'; // Your current policy version + + const userVersion = request.user.get('currentPolicyVersion'); + + if (userVersion !== currentVersion) { + return { + needsAcceptance: true, + currentVersion: currentVersion, + userVersion: userVersion + }; + } + + return { needsAcceptance: false }; +}, { + requireUser: true +}); +``` + +--- + +### 7. Data Breach Response + +**What:** Detect and respond to data breaches within 72 hours. + +**Your Responsibility:** +- Monitor for breaches +- Document breach details +- Notify supervisory authority within 72 hours +- Notify affected users if high risk + +**Implementation Example:** + +**Monitor Audit Logs for Suspicious Activity:** +```javascript +Parse.Cloud.job('detectBreaches', async (request) => { + const { message } = request; + + // Read recent audit logs and detect anomalies + // Example: Multiple failed login attempts + const fs = require('fs'); + const readline = require('readline'); + + const logFile = './audit-logs/parse-server-audit-2025-10-01.log'; + const fileStream = fs.createReadStream(logFile); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + const failedLogins = {}; + + for await (const line of rl) { + try { + const entry = JSON.parse(line); + + if (entry.eventType === 'USER_LOGIN' && entry.success === false) { + const ip = entry.ipAddress; + failedLogins[ip] = (failedLogins[ip] || 0) + 1; + + // Alert if > 10 failed attempts from same IP + if (failedLogins[ip] > 10) { + await notifySecurityTeam({ + type: 'POTENTIAL_BREACH', + details: `Multiple failed login attempts from IP: ${ip}`, + count: failedLogins[ip] + }); + } + } + } catch (e) { + // Skip invalid lines + } + } +}); +``` + +**Document Breach:** +```javascript +const breachSchema = new Parse.Schema('DataBreach'); +breachSchema.addString('type'); +breachSchema.addString('description'); +breachSchema.addDate('detectedAt'); +breachSchema.addDate('occurredAt'); +breachSchema.addNumber('affectedUsers'); +breachSchema.addBoolean('authorityNotified'); +breachSchema.addBoolean('usersNotified'); +breachSchema.save(); +``` + +--- + +## Part 3: What Your Organization Must Ensure 🏢 + +Beyond code, GDPR requires organizational and infrastructure measures. + +### Infrastructure & Deployment + +#### 1. Data Residency (if serving EU users) +- [ ] **Host database in EU region** + - MongoDB Atlas: Frankfurt, Ireland, or London + - AWS RDS/DocumentDB: eu-west-1, eu-central-1 + - Azure: West Europe, North Europe + - Google Cloud: europe-west1, europe-west2 + +- [ ] **Host Parse Server in EU region** + - Use EU-based servers or cloud regions + +- [ ] **Store backups in EU region** + - Ensure backup location complies with data residency + +#### 2. Encryption +- [ ] **Encryption at Rest** + - Enable database encryption (MongoDB: encryption at rest, PostgreSQL: TDE) + - Encrypt file storage + - Encrypt backups + +- [ ] **Encryption in Transit** + - HTTPS only (no HTTP) + - TLS 1.2 or higher + - Encrypted database connections (SSL/TLS) + +#### 3. Access Control +- [ ] **Strong Master Key** + - Generate cryptographically secure master key + - Rotate regularly (e.g., annually) + - Store securely (environment variables, secrets manager) + +- [ ] **Role-Based Access Control** + - Limit who can access Parse Server + - Limit who can access database + - Use Parse Server's ACL system + +- [ ] **Multi-Factor Authentication** + - Enable for all admin accounts + - Enable for Parse Dashboard + +#### 4. Backup & Recovery +- [ ] **Regular Backups** + - Daily automated backups + - Test restore procedures regularly + +- [ ] **Backup Encryption** + - Encrypt all backups + +- [ ] **Backup Retention** + - Define retention policy (e.g., 30 days) + - Balance recovery needs vs. data minimization + +#### 5. Audit Log Management +- [ ] **Secure Storage** + - Store audit logs separately from application data + - Use append-only storage (prevent tampering) + +- [ ] **Long-term Retention** + - Retain for 1-2 years minimum + - Comply with local regulations + +- [ ] **Regular Review** + - Review logs for anomalies + - Monitor for potential breaches + +- [ ] **Backup Audit Logs** + - Backup to immutable storage + - Consider blockchain for tamper-evidence + +### Legal & Compliance + +#### 1. Legal Basis for Processing +Document the legal basis for each processing activity: +- **Consent** - User explicitly agreed +- **Contract** - Necessary to fulfill a contract +- **Legal Obligation** - Required by law +- **Vital Interests** - Protect life +- **Public Task** - Official function +- **Legitimate Interests** - Your business needs (balanced against user rights) + +#### 2. Privacy Policy +Create and publish: +- [ ] What data you collect +- [ ] How you use it +- [ ] How long you retain it +- [ ] User rights (access, erasure, portability, etc.) +- [ ] Contact information +- [ ] DPO contact (if applicable) +- [ ] How to file a complaint + +#### 3. Data Processing Agreements (DPAs) +Sign DPAs with all third-party processors: +- [ ] Database provider (MongoDB Atlas, AWS, etc.) +- [ ] Cloud provider (AWS, Azure, GCP) +- [ ] Email service (SendGrid, Mailgun, etc.) +- [ ] Analytics service (if used) +- [ ] Error tracking service (Sentry, etc.) +- [ ] Any other service that processes personal data + +#### 4. Standard Contractual Clauses (SCCs) +For data transfers outside the EU: +- [ ] Ensure SCCs are in place with non-EU processors +- [ ] Alternative: Use processors with EU Adequacy Decision (UK, Canada, Japan, etc.) + +#### 5. Data Protection Officer (DPO) +Appoint a DPO if: +- You have >250 employees, OR +- You process large-scale special category data, OR +- You systematically monitor individuals + +#### 6. Data Protection Impact Assessment (DPIA) +Conduct DPIA for high-risk processing: +- Large-scale profiling +- Special category data +- Systematic monitoring +- Automated decision-making with legal effects + +### Processes + +#### 1. Data Subject Rights Request Process +- [ ] **Procedure to verify requesters** - Ensure they are who they claim +- [ ] **30-day response deadline** - Respond within one month +- [ ] **Free of charge** - Don't charge for first request +- [ ] **Request tracking** - Log all requests +- [ ] **Escalation process** - Handle complex requests + +#### 2. Data Breach Response Plan +- [ ] **Detection procedures** - Monitor for breaches +- [ ] **72-hour notification to authority** - EU supervisory authority +- [ ] **User notification** - If high risk to users +- [ ] **Breach documentation** - Record all breaches +- [ ] **Post-mortem analysis** - Learn from incidents + +#### 3. Vendor Management +- [ ] **Annual compliance reviews** - Verify vendor GDPR compliance +- [ ] **DPA renewals** - Keep agreements current +- [ ] **Vendor risk assessment** - Evaluate new vendors + +### Training & Documentation + +#### 1. Staff Training +- [ ] **Annual GDPR training** - All staff +- [ ] **Developer training** - Privacy by design +- [ ] **Security training** - Incident response +- [ ] **Training records** - Document all training + +#### 2. Documentation +- [ ] **Records of Processing Activities** (Article 30) + - What data you process + - Why you process it + - Who you share it with + - How long you retain it + +- [ ] **Data Inventory** - List all personal data + +- [ ] **Data Flow Map** - Visualize data flows + +- [ ] **Data Retention Schedule** - Retention period for each type + +--- + +## GDPR Compliance Checklist Summary + +### ✅ Parse Server Provides +- [x] Audit logging infrastructure +- [x] Configuration options +- [x] Documentation and examples + +### 👨‍💻 You Must Implement (Code) +- [ ] Data export function (`exportMyData`) +- [ ] Data deletion function (`deleteMyData`) +- [ ] Consent management (schema + Cloud Code) +- [ ] Data retention job (`enforceDataRetention`) +- [ ] Privacy policy tracking +- [ ] Breach detection and response + +### 🏢 Your Organization Must Ensure +- [ ] EU infrastructure (if applicable) +- [ ] Encryption (at rest and in transit) +- [ ] Access control and security +- [ ] Privacy policy and legal documents +- [ ] Data Processing Agreements +- [ ] Staff training +- [ ] Data protection processes + +--- + +## Frequently Asked Questions + +### Is Parse Server GDPR compliant? + +**Parse Server provides audit logging infrastructure.** The rest of GDPR compliance depends on: +1. How you implement your application (Cloud Code functions) +2. How you deploy Parse Server (infrastructure, security) +3. How your organization operates (policies, training, processes) + +Parse Server gives you the tools. You build the compliance. + +### Do I need to host in the EU? + +**Only if you process data of EU residents.** If your users are in the EU, GDPR applies and you should: +- Host in EU region, OR +- Use Standard Contractual Clauses (SCCs) for data transfer + +### How long should I retain audit logs? + +**Recommendation: 1-2 years minimum.** This gives you: +- Time to detect and respond to breaches +- Evidence for compliance audits +- Historical data for investigations + +Check your local regulations for specific requirements. + +### What if I can't delete data due to legal requirements? + +**You can refuse deletion if you have a legal obligation to retain data** (Article 17.3). Examples: +- Financial records (tax law: 7-10 years) +- Medical records (varies by jurisdiction) +- Legal disputes (retain until resolved) + +**Solution:** Anonymize instead of delete. Remove identifying information but keep the record. + +### How do I handle data export for large datasets? + +**Use pagination and background jobs:** + +```javascript +Parse.Cloud.define('requestDataExport', async (request) => { + // Create export job + const exportJob = new Parse.Object('ExportJob'); + exportJob.set('user', request.user); + exportJob.set('status', 'pending'); + await exportJob.save(null, { useMasterKey: true }); + + // Process in background + Parse.Cloud.startJob('processExport', { exportJobId: exportJob.id }); + + return { + jobId: exportJob.id, + message: 'Export started. You will receive an email when ready.' + }; +}, { + requireUser: true +}); +``` + +### What about user data in external services (email, analytics, etc.)? + +**You're responsible for the entire data ecosystem.** When a user requests deletion: +1. Delete from Parse Server (your code) +2. Delete from email service (their API) +3. Delete from analytics (their API) +4. Delete from any other service + +Document all data flows and implement deletion across all systems. + +--- + +## Resources + +### Parse Server GDPR Documentation +- [GDPR_AUDIT_LOGGING.md](./GDPR_AUDIT_LOGGING.md) - Audit logging setup and configuration + +### External Resources +- [Official GDPR Text](https://gdpr-info.eu/) - Complete regulation text +- [ICO GDPR Guide](https://ico.org.uk/for-organisations/guide-to-data-protection/guide-to-the-general-data-protection-regulation-gdpr/) - UK guidance +- [CNIL GDPR Resources](https://www.cnil.fr/en/home) - French supervisory authority +- [EDPB Guidelines](https://edpb.europa.eu/our-work-tools/general-guidance/gdpr-guidelines-recommendations-best-practices_en) - EU guidelines + +### Tools +- [jq](https://stedolan.github.io/jq/) - Command-line JSON processor for querying audit logs +- [MongoDB Compass](https://www.mongodb.com/products/compass) - GUI for MongoDB +- [Parse Dashboard](https://github.com/parse-community/parse-dashboard) - Parse Server admin UI + +--- + +## Support + +For Parse Server-specific questions: +- [Parse Community Forum](https://community.parseplatform.org/) +- [GitHub Issues](https://github.com/parse-community/parse-server/issues) + +For legal compliance questions: +- Consult a qualified attorney +- Contact your local Data Protection Authority + +--- + +## License + +This guide is provided as-is for informational purposes. It does not constitute legal advice. Consult with qualified legal counsel for compliance guidance specific to your situation. diff --git a/spec/AuditLogAdapter.spec.js b/spec/AuditLogAdapter.spec.js new file mode 100644 index 0000000000..46ed79aeb3 --- /dev/null +++ b/spec/AuditLogAdapter.spec.js @@ -0,0 +1,465 @@ +'use strict'; + +const AuditLogAdapter = require('../lib/Adapters/Logger/AuditLogAdapter').AuditLogAdapter; +const { configureAuditLogger, logAuditEvent, isAuditLogEnabled } = require('../lib/Adapters/Logger/AuditLogger'); +const fs = require('fs'); +const path = require('path'); + +describe('AuditLogAdapter', () => { + const testLogFolder = path.join(__dirname, 'temp-audit-logs'); + + beforeEach(() => { + // Clean up test log folder + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + afterEach(() => { + // Clean up test log folder + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + describe('constructor', () => { + it('should initialize without options', () => { + const adapter = new AuditLogAdapter(); + expect(adapter).toBeDefined(); + }); + + it('should initialize with auditLogFolder option', () => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + expect(adapter).toBeDefined(); + expect(fs.existsSync(testLogFolder)).toBe(true); + }); + + it('should not create folder without auditLogFolder option', () => { + const adapter = new AuditLogAdapter({}); + expect(adapter).toBeDefined(); + expect(fs.existsSync(testLogFolder)).toBe(false); + }); + }); + + describe('isEnabled', () => { + it('should return false when not configured', () => { + const adapter = new AuditLogAdapter(); + expect(adapter.isEnabled()).toBe(false); + }); + + it('should return true when configured with folder', () => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + expect(adapter.isEnabled()).toBe(true); + }); + }); + + describe('log', () => { + it('should not throw when audit logging is disabled', () => { + const adapter = new AuditLogAdapter(); + expect(() => { + adapter.log('info', 'test message', { key: 'value' }); + }).not.toThrow(); + }); + + it('should log system event when enabled', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.log('info', 'test message', { key: 'value' }); + + // Give winston time to write + setTimeout(() => { + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + done(); + }, 100); + }); + }); + + describe('logUserLogin', () => { + it('should not throw when disabled', () => { + const adapter = new AuditLogAdapter(); + expect(() => { + adapter.logUserLogin({ + userId: 'user1', + username: 'testuser', + sessionToken: 'token123', + ipAddress: '127.0.0.1', + success: true, + }); + }).not.toThrow(); + }); + + it('should log successful login', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logUserLogin({ + userId: 'user1', + username: 'testuser', + sessionToken: 'token123', + ipAddress: '127.0.0.1', + success: true, + loginMethod: 'password', + }); + + setTimeout(() => { + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('USER_LOGIN'); + expect(logContent).toContain('testuser'); + expect(logContent).toContain('***masked***'); // Session token should be masked + expect(logContent).not.toContain('token123'); + done(); + }, 100); + }); + + it('should log failed login', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logUserLogin({ + userId: 'user1', + username: 'testuser', + ipAddress: '127.0.0.1', + success: false, + error: 'Invalid credentials', + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('USER_LOGIN'); + expect(logContent).toContain('Invalid credentials'); + expect(logContent).toContain('"success":false'); + done(); + }, 100); + }); + }); + + describe('logDataView', () => { + it('should not throw when disabled', () => { + const adapter = new AuditLogAdapter(); + expect(() => { + adapter.logDataView({ + userId: 'user1', + className: 'TestClass', + query: {}, + resultCount: 5, + }); + }).not.toThrow(); + }); + + it('should log data view event', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataView({ + userId: 'user1', + sessionToken: 'token123', + ipAddress: '127.0.0.1', + className: 'TestClass', + query: { name: 'test' }, + resultCount: 5, + objectIds: ['obj1', 'obj2'], + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('DATA_VIEW'); + expect(logContent).toContain('TestClass'); + expect(logContent).toContain('obj1'); + done(); + }, 100); + }); + }); + + describe('logDataCreate', () => { + it('should log data creation', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataCreate({ + userId: 'user1', + ipAddress: '127.0.0.1', + className: 'TestClass', + objectId: 'obj1', + data: { name: 'test', value: 123 }, + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('DATA_CREATE'); + expect(logContent).toContain('TestClass'); + expect(logContent).toContain('obj1'); + done(); + }, 100); + }); + + it('should log failed creation', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataCreate({ + userId: 'user1', + className: 'TestClass', + objectId: 'obj1', + data: {}, + success: false, + error: 'Validation failed', + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('Validation failed'); + expect(logContent).toContain('"success":false'); + done(); + }, 100); + }); + }); + + describe('logDataUpdate', () => { + it('should log data update', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataUpdate({ + userId: 'user1', + ipAddress: '127.0.0.1', + className: 'TestClass', + objectId: 'obj1', + updatedFields: { name: 'updated' }, + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('DATA_UPDATE'); + expect(logContent).toContain('TestClass'); + expect(logContent).toContain('obj1'); + done(); + }, 100); + }); + }); + + describe('logDataDelete', () => { + it('should log data deletion', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataDelete({ + userId: 'user1', + ipAddress: '127.0.0.1', + className: 'TestClass', + objectId: 'obj1', + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('DATA_DELETE'); + expect(logContent).toContain('TestClass'); + expect(logContent).toContain('obj1'); + done(); + }, 100); + }); + }); + + describe('logACLModify', () => { + it('should log ACL modification', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + const oldACL = { '*': { read: true } }; + const newACL = { '*': { read: true }, user1: { write: true } }; + + adapter.logACLModify({ + userId: 'user1', + ipAddress: '127.0.0.1', + className: 'TestClass', + objectId: 'obj1', + oldACL, + newACL, + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('ACL_MODIFY'); + expect(logContent).toContain('TestClass'); + expect(logContent).toContain('obj1'); + done(); + }, 100); + }); + }); + + describe('logSchemaModify', () => { + it('should log schema creation', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logSchemaModify({ + userId: 'user1', + ipAddress: '127.0.0.1', + className: 'NewClass', + operation: 'create', + changes: { fields: { name: { type: 'String' } } }, + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('SCHEMA_MODIFY'); + expect(logContent).toContain('NewClass'); + expect(logContent).toContain('create'); + done(); + }, 100); + }); + + it('should log schema update', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logSchemaModify({ + userId: 'user1', + className: 'ExistingClass', + operation: 'update', + changes: { fields: { age: { type: 'Number' } } }, + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('update'); + done(); + }, 100); + }); + + it('should log schema deletion', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logSchemaModify({ + userId: 'user1', + className: 'OldClass', + operation: 'delete', + changes: {}, + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('delete'); + done(); + }, 100); + }); + }); + + describe('logPushSend', () => { + it('should log push notification', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logPushSend({ + userId: 'user1', + ipAddress: '127.0.0.1', + query: { deviceType: 'ios' }, + channels: ['channel1', 'channel2'], + targetCount: 100, + success: true, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('PUSH_SEND'); + expect(logContent).toContain('channel1'); + done(); + }, 100); + }); + + it('should log failed push', done => { + const adapter = new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logPushSend({ + userId: 'user1', + query: {}, + channels: [], + targetCount: 0, + success: false, + error: 'No devices found', + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, fs.readdirSync(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('No devices found'); + expect(logContent).toContain('"success":false'); + done(); + }, 100); + }); + }); + + describe('log file management', () => { + it('should create log folder if it does not exist', () => { + expect(fs.existsSync(testLogFolder)).toBe(false); + + new AuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + expect(fs.existsSync(testLogFolder)).toBe(true); + }); + + it('should create logs with date pattern in filename', done => { + new AuditLogAdapter({ + auditLogFolder: testLogFolder, + datePattern: 'YYYY-MM-DD', + }); + + logAuditEvent({ + eventType: 'SYSTEM', + action: 'test', + }); + + setTimeout(() => { + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + expect(logFiles[0]).toMatch(/parse-server-audit-\d{4}-\d{2}-\d{2}\.log/); + done(); + }, 100); + }); + }); +}); diff --git a/spec/AuditLogController.spec.js b/spec/AuditLogController.spec.js new file mode 100644 index 0000000000..aeeb49bec6 --- /dev/null +++ b/spec/AuditLogController.spec.js @@ -0,0 +1,485 @@ +'use strict'; + +const AuditLogController = require('../lib/Controllers/AuditLogController').AuditLogController; +const AuditLogAdapter = require('../lib/Adapters/Logger/AuditLogAdapter').AuditLogAdapter; + +describe('AuditLogController', () => { + let controller; + let mockAdapter; + + beforeEach(() => { + mockAdapter = { + logUserLogin: jasmine.createSpy('logUserLogin'), + logDataView: jasmine.createSpy('logDataView'), + logDataCreate: jasmine.createSpy('logDataCreate'), + logDataUpdate: jasmine.createSpy('logDataUpdate'), + logDataDelete: jasmine.createSpy('logDataDelete'), + logACLModify: jasmine.createSpy('logACLModify'), + logSchemaModify: jasmine.createSpy('logSchemaModify'), + logPushSend: jasmine.createSpy('logPushSend'), + isEnabled: jasmine.createSpy('isEnabled').and.returnValue(true), + }; + + controller = new AuditLogController(mockAdapter, 'testApp'); + }); + + describe('constructor', () => { + it('should initialize with adapter', () => { + expect(controller.adapter).toBe(mockAdapter); + }); + }); + + describe('_getIPAddress', () => { + it('should extract IP from x-forwarded-for header', () => { + const req = { + headers: { 'x-forwarded-for': '192.168.1.1, 10.0.0.1' }, + ip: '127.0.0.1', + }; + const ip = controller._getIPAddress(req); + expect(ip).toBe('192.168.1.1'); + }); + + it('should extract IP from x-real-ip header', () => { + const req = { + headers: { 'x-real-ip': '192.168.1.2' }, + ip: '127.0.0.1', + }; + const ip = controller._getIPAddress(req); + expect(ip).toBe('192.168.1.2'); + }); + + it('should fallback to req.ip', () => { + const req = { + headers: {}, + ip: '127.0.0.1', + }; + const ip = controller._getIPAddress(req); + expect(ip).toBe('127.0.0.1'); + }); + + it('should fallback to connection.remoteAddress', () => { + const req = { + headers: {}, + connection: { remoteAddress: '127.0.0.2' }, + }; + const ip = controller._getIPAddress(req); + expect(ip).toBe('127.0.0.2'); + }); + + it('should return undefined for null request', () => { + const ip = controller._getIPAddress(null); + expect(ip).toBeUndefined(); + }); + }); + + describe('_getUserContext', () => { + it('should extract user ID and session token from auth', () => { + const auth = { + user: { id: 'user123' }, + sessionToken: 'session123', + }; + const context = controller._getUserContext(auth); + expect(context.userId).toBe('user123'); + expect(context.sessionToken).toBe('session123'); + }); + + it('should handle auth with objectId instead of id', () => { + const auth = { + user: { objectId: 'user456' }, + sessionToken: 'session456', + }; + const context = controller._getUserContext(auth); + expect(context.userId).toBe('user456'); + }); + + it('should return undefined values for null auth', () => { + const context = controller._getUserContext(null); + expect(context.userId).toBeUndefined(); + expect(context.sessionToken).toBeUndefined(); + }); + + it('should handle auth without user', () => { + const auth = { + sessionToken: 'session789', + }; + const context = controller._getUserContext(auth); + expect(context.userId).toBeUndefined(); + expect(context.sessionToken).toBe('session789'); + }); + }); + + describe('_maskSensitiveData', () => { + it('should mask password field', () => { + const data = { username: 'test', password: 'secret123' }; + const masked = controller._maskSensitiveData(data); + expect(masked.username).toBe('test'); + expect(masked.password).toBe('***masked***'); + }); + + it('should mask sessionToken field', () => { + const data = { userId: 'user1', sessionToken: 'token123' }; + const masked = controller._maskSensitiveData(data); + expect(masked.userId).toBe('user1'); + expect(masked.sessionToken).toBe('***masked***'); + }); + + it('should mask authData field', () => { + const data = { username: 'test', authData: { facebook: {} } }; + const masked = controller._maskSensitiveData(data); + expect(masked.authData).toBe('***masked***'); + }); + + it('should mask _hashed_password field', () => { + const data = { username: 'test', _hashed_password: 'hash123' }; + const masked = controller._maskSensitiveData(data); + expect(masked._hashed_password).toBe('***masked***'); + }); + + it('should return non-object data unchanged', () => { + expect(controller._maskSensitiveData(null)).toBe(null); + expect(controller._maskSensitiveData('string')).toBe('string'); + expect(controller._maskSensitiveData(123)).toBe(123); + }); + + it('should not mutate original data', () => { + const data = { password: 'secret' }; + controller._maskSensitiveData(data); + expect(data.password).toBe('secret'); + }); + }); + + describe('logUserLogin', () => { + it('should log successful login', () => { + const params = { + auth: { user: { id: 'user1' }, sessionToken: 'token1' }, + req: { headers: {}, ip: '127.0.0.1' }, + username: 'testuser', + success: true, + loginMethod: 'password', + }; + + controller.logUserLogin(params); + + expect(mockAdapter.logUserLogin).toHaveBeenCalledWith({ + userId: 'user1', + username: 'testuser', + sessionToken: 'token1', + ipAddress: '127.0.0.1', + success: true, + error: undefined, + loginMethod: 'password', + }); + }); + + it('should log failed login', () => { + const params = { + auth: {}, + req: { headers: {}, ip: '127.0.0.1' }, + username: 'testuser', + success: false, + error: 'Invalid credentials', + }; + + controller.logUserLogin(params); + + expect(mockAdapter.logUserLogin).toHaveBeenCalledWith( + jasmine.objectContaining({ + username: 'testuser', + success: false, + error: 'Invalid credentials', + }) + ); + }); + + it('should not log if adapter is disabled', () => { + mockAdapter.isEnabled.and.returnValue(false); + controller.logUserLogin({ auth: {}, req: {} }); + expect(mockAdapter.logUserLogin).not.toHaveBeenCalled(); + }); + + it('should not log if adapter is null', () => { + controller.adapter = null; + controller.logUserLogin({ auth: {}, req: {} }); + // Should not throw error + }); + }); + + describe('logDataView', () => { + it('should log data view operation', () => { + const params = { + auth: { user: { id: 'user1' }, sessionToken: 'token1' }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'TestClass', + query: { name: 'test' }, + resultCount: 5, + objectIds: ['obj1', 'obj2'], + }; + + controller.logDataView(params); + + expect(mockAdapter.logDataView).toHaveBeenCalledWith({ + userId: 'user1', + sessionToken: 'token1', + ipAddress: '127.0.0.1', + className: 'TestClass', + query: { name: 'test' }, + resultCount: 5, + objectIds: ['obj1', 'obj2'], + }); + }); + + it('should not log if adapter is disabled', () => { + mockAdapter.isEnabled.and.returnValue(false); + controller.logDataView({ auth: {}, req: {}, className: 'Test' }); + expect(mockAdapter.logDataView).not.toHaveBeenCalled(); + }); + }); + + describe('logDataCreate', () => { + it('should log data creation with masked sensitive data', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: '_User', + objectId: 'newUser1', + data: { username: 'newuser', password: 'secret123' }, + success: true, + }; + + controller.logDataCreate(params); + + expect(mockAdapter.logDataCreate).toHaveBeenCalledWith( + jasmine.objectContaining({ + className: '_User', + objectId: 'newUser1', + data: jasmine.objectContaining({ + username: 'newuser', + password: '***masked***', + }), + }) + ); + }); + + it('should log failed creation', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'TestClass', + objectId: 'obj1', + data: {}, + success: false, + error: 'Validation failed', + }; + + controller.logDataCreate(params); + + expect(mockAdapter.logDataCreate).toHaveBeenCalledWith( + jasmine.objectContaining({ + success: false, + error: 'Validation failed', + }) + ); + }); + }); + + describe('logDataUpdate', () => { + it('should log data update with masked fields', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: '_User', + objectId: 'user1', + updatedFields: { email: 'new@example.com', password: 'newsecret' }, + success: true, + }; + + controller.logDataUpdate(params); + + expect(mockAdapter.logDataUpdate).toHaveBeenCalledWith( + jasmine.objectContaining({ + className: '_User', + objectId: 'user1', + updatedFields: jasmine.objectContaining({ + email: 'new@example.com', + password: '***masked***', + }), + }) + ); + }); + }); + + describe('logDataDelete', () => { + it('should log data deletion', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'TestClass', + objectId: 'obj1', + success: true, + }; + + controller.logDataDelete(params); + + expect(mockAdapter.logDataDelete).toHaveBeenCalledWith({ + userId: 'user1', + sessionToken: undefined, + ipAddress: '127.0.0.1', + className: 'TestClass', + objectId: 'obj1', + success: true, + error: undefined, + }); + }); + }); + + describe('logACLModify', () => { + it('should log ACL modification', () => { + const oldACL = { '*': { read: true } }; + const newACL = { '*': { read: true }, user1: { write: true } }; + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'TestClass', + objectId: 'obj1', + oldACL, + newACL, + success: true, + }; + + controller.logACLModify(params); + + expect(mockAdapter.logACLModify).toHaveBeenCalledWith( + jasmine.objectContaining({ + className: 'TestClass', + objectId: 'obj1', + oldACL, + newACL, + }) + ); + }); + }); + + describe('logSchemaModify', () => { + it('should log schema creation', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'NewClass', + operation: 'create', + changes: { fields: { name: { type: 'String' } } }, + success: true, + }; + + controller.logSchemaModify(params); + + expect(mockAdapter.logSchemaModify).toHaveBeenCalledWith( + jasmine.objectContaining({ + className: 'NewClass', + operation: 'create', + changes: params.changes, + }) + ); + }); + + it('should log schema update', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'ExistingClass', + operation: 'update', + changes: { fields: { age: { type: 'Number' } } }, + success: true, + }; + + controller.logSchemaModify(params); + + expect(mockAdapter.logSchemaModify).toHaveBeenCalledWith( + jasmine.objectContaining({ + operation: 'update', + }) + ); + }); + + it('should log schema deletion', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'OldClass', + operation: 'delete', + changes: {}, + success: true, + }; + + controller.logSchemaModify(params); + + expect(mockAdapter.logSchemaModify).toHaveBeenCalledWith( + jasmine.objectContaining({ + operation: 'delete', + }) + ); + }); + }); + + describe('logPushSend', () => { + it('should log push notification', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + query: { deviceType: 'ios' }, + channels: ['channel1', 'channel2'], + targetCount: 100, + success: true, + }; + + controller.logPushSend(params); + + expect(mockAdapter.logPushSend).toHaveBeenCalledWith({ + userId: 'user1', + sessionToken: undefined, + ipAddress: '127.0.0.1', + query: { deviceType: 'ios' }, + channels: ['channel1', 'channel2'], + targetCount: 100, + success: true, + error: undefined, + }); + }); + + it('should log failed push', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + query: {}, + channels: [], + targetCount: 0, + success: false, + error: 'No devices found', + }; + + controller.logPushSend(params); + + expect(mockAdapter.logPushSend).toHaveBeenCalledWith( + jasmine.objectContaining({ + success: false, + error: 'No devices found', + }) + ); + }); + }); + + describe('isEnabled', () => { + it('should return true when adapter is enabled', () => { + expect(controller.isEnabled()).toBe(true); + }); + + it('should return false when adapter is disabled', () => { + mockAdapter.isEnabled.and.returnValue(false); + expect(controller.isEnabled()).toBe(false); + }); + + it('should return false when adapter is null', () => { + controller.adapter = null; + expect(controller.isEnabled()).toBe(false); + }); + }); +}); diff --git a/spec/AuditLogSchemas.spec.js b/spec/AuditLogSchemas.spec.js new file mode 100644 index 0000000000..50d60532bb --- /dev/null +++ b/spec/AuditLogSchemas.spec.js @@ -0,0 +1,247 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const request = require('../lib/request'); + +describe('Audit Logging - Schema Operations', () => { + const testLogFolder = path.join(__dirname, 'temp-audit-logs-schema'); + + beforeEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + }); + + afterEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + it('should log schema creation', async () => { + const schema = { + className: 'AuditSchemaTest', + fields: { + testField: { type: 'String' }, + }, + }; + + await request({ + method: 'POST', + url: Parse.serverURL + '/schemas', + body: schema, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('SCHEMA_MODIFY'); + expect(logContent).toContain('AuditSchemaTest'); + expect(logContent).toContain('create'); + }); + + it('should log schema update', async () => { + const schema = { + className: 'AuditSchemaUpdate', + fields: { + field1: { type: 'String' }, + }, + }; + + await request({ + method: 'POST', + url: Parse.serverURL + '/schemas', + body: schema, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + const logFiles1 = fs.readdirSync(testLogFolder); + if (logFiles1.length > 0) { + fs.unlinkSync(path.join(testLogFolder, logFiles1[0])); + } + + await request({ + method: 'PUT', + url: Parse.serverURL + '/schemas/AuditSchemaUpdate', + body: { + fields: { + field2: { type: 'Number' }, + }, + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('SCHEMA_MODIFY'); + expect(logContent).toContain('AuditSchemaUpdate'); + expect(logContent).toContain('update'); + }); + + it('should log schema deletion', async () => { + const schema = { + className: 'AuditSchemaDelete', + fields: { + field1: { type: 'String' }, + }, + }; + + await request({ + method: 'POST', + url: Parse.serverURL + '/schemas', + body: schema, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + const logFiles1 = fs.readdirSync(testLogFolder); + if (logFiles1.length > 0) { + fs.unlinkSync(path.join(testLogFolder, logFiles1[0])); + } + + await request({ + method: 'DELETE', + url: Parse.serverURL + '/schemas/AuditSchemaDelete', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('SCHEMA_MODIFY'); + expect(logContent).toContain('AuditSchemaDelete'); + expect(logContent).toContain('delete'); + }); + + it('should log failed schema creation', async () => { + const schema = { + className: '_InvalidClassName', + fields: { + testField: { type: 'String' }, + }, + }; + + try { + await request({ + method: 'POST', + url: Parse.serverURL + '/schemas', + body: schema, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + } catch (error) { + // Expected to fail + } + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + if (logFiles.length > 0) { + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('SCHEMA_MODIFY'); + expect(logContent).toContain('"success":false'); + } + }); + + it('should capture user context in schema logs', async () => { + const schema = { + className: 'AuditSchemaUser', + fields: { + field1: { type: 'String' }, + }, + }; + + await request({ + method: 'POST', + url: Parse.serverURL + '/schemas', + body: schema, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('SCHEMA_MODIFY'); + }); + + it('should log schema changes with field details', async () => { + const schema = { + className: 'AuditSchemaFields', + fields: { + stringField: { type: 'String' }, + numberField: { type: 'Number' }, + pointerField: { type: 'Pointer', targetClass: '_User' }, + }, + }; + + await request({ + method: 'POST', + url: Parse.serverURL + '/schemas', + body: schema, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('SCHEMA_MODIFY'); + expect(logContent).toContain('stringField'); + expect(logContent).toContain('numberField'); + }); +}); diff --git a/spec/AuditLogging.e2e.spec.js b/spec/AuditLogging.e2e.spec.js new file mode 100644 index 0000000000..7738e8afa5 --- /dev/null +++ b/spec/AuditLogging.e2e.spec.js @@ -0,0 +1,464 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const request = require('../lib/request'); + +describe('End-to-End Audit Logging', () => { + const testLogFolder = path.join(__dirname, 'temp-audit-logs-e2e'); + + beforeEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + afterEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + describe('Complete User Lifecycle with Audit Trail', () => { + it('should create complete audit trail for user signup → login → CRUD → logout', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'e2euser', + password: 'password123', + email: 'e2e@example.com', + }); + + await Parse.User.logOut(); + await Parse.User.logIn('e2euser', 'password123'); + + const TestClass = Parse.Object.extend('E2ETest'); + const obj = new TestClass(); + obj.set('name', 'test object'); + await obj.save(); + + const query = new Parse.Query('E2ETest'); + const results = await query.find(); + + obj.set('name', 'updated test object'); + await obj.save(); + + await obj.destroy(); + + await new Promise(resolve => setTimeout(resolve, 500)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('USER_LOGIN'); + expect(logContent).toContain('DATA_CREATE'); + expect(logContent).toContain('DATA_VIEW'); + expect(logContent).toContain('DATA_UPDATE'); + expect(logContent).toContain('DATA_DELETE'); + + const logLines = logContent + .split('\n') + .filter(line => line.trim().length > 0) + .map(line => JSON.parse(line)); + + // Verify timestamps are in chronological order + const timestamps = logLines.map(log => new Date(log.timestamp).getTime()); + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + }); + }); + + describe('Audit Log File Management', () => { + it('should create log files with correct naming pattern', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + datePattern: 'YYYY-MM-DD', + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'filenameuser', + password: 'password123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const filenamePattern = /parse-server-audit-\d{4}-\d{2}-\d{2}\.log/; + expect(logFiles[0]).toMatch(filenamePattern); + }); + + it('should create folder if it does not exist', async () => { + const newFolder = path.join(testLogFolder, 'nested', 'logs'); + + await reconfigureServer({ + auditLog: { + auditLogFolder: newFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'folderuser', + password: 'password123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(fs.existsSync(newFolder)).toBe(true); + + const logFiles = fs.readdirSync(newFolder); + expect(logFiles.length).toBeGreaterThan(0); + + fs.rmSync(path.join(testLogFolder, 'nested'), { recursive: true, force: true }); + }); + + it('should handle custom date patterns', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + datePattern: 'YYYY-MM', + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'datepatternuser', + password: 'password123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const filenamePattern = /parse-server-audit-\d{4}-\d{2}\.log/; + expect(logFiles[0]).toMatch(filenamePattern); + }); + }); + + describe('Audit Logging Configuration', () => { + it('should not create logs when audit logging is disabled', async () => { + await reconfigureServer({}); + + const user = new Parse.User(); + await user.signUp({ + username: 'disableduser', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('DisabledAudit'); + const obj = new TestClass(); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(fs.existsSync(testLogFolder)).toBe(false); + }); + + it('should support enabling audit logging at runtime', async () => { + await reconfigureServer({}); + + const user1 = new Parse.User(); + await user1.signUp({ + username: 'runtimeuser1', + password: 'password123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + expect(fs.existsSync(testLogFolder)).toBe(false); + + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + await Parse.User.logOut(); + const user2 = new Parse.User(); + await user2.signUp({ + username: 'runtimeuser2', + password: 'password123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(fs.existsSync(testLogFolder)).toBe(true); + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + }); + }); + + describe('Audit Log Content Validation', () => { + it('should log all required fields for each event type', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'validationuser', + password: 'password123', + }); + + await Parse.User.logOut(); + await Parse.User.logIn('validationuser', 'password123'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const logLines = logContent.split('\n').filter(line => line.includes('USER_LOGIN')); + + expect(logLines.length).toBeGreaterThan(0); + + const loginLog = JSON.parse(logLines[0]); + + expect(loginLog.timestamp).toBeDefined(); + expect(loginLog.eventType).toBe('USER_LOGIN'); + expect(loginLog.userId).toBeDefined(); + expect(loginLog.action).toBeDefined(); + expect(loginLog.success).toBeDefined(); + expect(loginLog.ipAddress).toBeDefined(); + }); + + it('should properly format JSON in log files', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'jsonuser', + password: 'password123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const logLines = logContent.split('\n').filter(line => line.trim().length > 0); + + for (const line of logLines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }); + + it('should mask all sensitive data fields', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'maskinguser', + password: 'supersecret123', + email: 'masking@example.com', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('***masked***'); + expect(logContent).not.toContain('supersecret123'); + expect(logContent).not.toContain(user.getSessionToken()); + }); + }); + + describe('Concurrent Operations', () => { + it('should handle concurrent operations correctly', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const userPromises = []; + for (let i = 0; i < 10; i++) { + const user = new Parse.User(); + userPromises.push( + user.signUp({ + username: `concurrent${i}`, + password: 'password123', + }) + ); + } + + await Promise.all(userPromises); + + await new Promise(resolve => setTimeout(resolve, 300)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const logLines = logContent.split('\n').filter(line => line.trim().length > 0); + + expect(logLines.length).toBeGreaterThanOrEqual(10); + + const parsedLogs = logLines.map(line => JSON.parse(line)); + expect(parsedLogs.length).toBeGreaterThanOrEqual(10); + + for (let i = 0; i < 10; i++) { + const userLogs = logLines.filter(line => line.includes(`concurrent${i}`)); + expect(userLogs.length).toBeGreaterThan(0); + } + }); + }); + + describe('Error Handling', () => { + it('should not fail operations if audit logging fails', async () => { + const invalidPath = '/invalid/readonly/path'; + + await reconfigureServer({ + auditLog: { + auditLogFolder: invalidPath, + }, + }); + + const user = new Parse.User(); + await expectAsync( + user.signUp({ + username: 'errorhandlinguser', + password: 'password123', + }) + ).toBeResolved(); + + expect(user.id).toBeDefined(); + }); + + it('should log failed operations with error messages', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'failuser', + password: 'password123', + }); + + try { + await Parse.User.logIn('failuser', 'wrongpassword'); + } catch (error) { + // Expected to fail + } + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + const logLines = logContent.split('\n').filter(line => line.includes('"success":false')); + expect(logLines.length).toBeGreaterThan(0); + + const failedLog = JSON.parse(logLines[0]); + expect(failedLog.success).toBe(false); + expect(failedLog.error).toBeDefined(); + }); + }); + + describe('Performance and Scalability', () => { + it('should handle high-volume logging without significant performance degradation', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'perfuser', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('PerfTest'); + + const startTime = Date.now(); + + for (let i = 0; i < 100; i++) { + const obj = new TestClass(); + obj.set('index', i); + await obj.save(); + } + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete in reasonable time even with audit logging enabled + expect(duration).toBeLessThan(10000); + + await new Promise(resolve => setTimeout(resolve, 300)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const createLogs = logContent.split('\n').filter(line => line.includes('DATA_CREATE')); + + expect(createLogs.length).toBeGreaterThanOrEqual(100); + }); + }); + + describe('Cross-Feature Integration', () => { + it('should log schema modifications', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const schema = { + className: 'CustomClass', + fields: { + customField: { type: 'String' }, + }, + }; + + await request({ + method: 'POST', + url: Parse.serverURL + '/schemas/CustomClass', + body: schema, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('SCHEMA_MODIFY'); + expect(logContent).toContain('CustomClass'); + expect(logContent).toContain('create'); + }); + }); +}); diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index a055cda5bc..a3e17e6476 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -1,5 +1,7 @@ 'use strict'; +const request = require('../lib/request'); + describe('Auth', () => { const { Auth, getAuthForSessionToken } = require('../lib/Auth.js'); const Config = require('../lib/Config'); @@ -254,3 +256,168 @@ describe('extendSessionOnUse', () => { expect(res2).toBe(false); }); }); + +describe('Audit Logging - User Authentication', () => { + const fs = require('fs'); + const path = require('path'); + const testLogFolder = path.join(__dirname, 'temp-audit-logs-auth'); + + beforeEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + afterEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + it('should log successful user login', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'audituser1', + password: 'password123', + }); + + await Parse.User.logOut(); + await Parse.User.logIn('audituser1', 'password123'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(fs.existsSync(testLogFolder)).toBe(true); + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('USER_LOGIN'); + expect(logContent).toContain('audituser1'); + expect(logContent).toContain('"success":true'); + expect(logContent).toContain('***masked***'); + }); + + it('should log failed login attempt', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'audituser2', + password: 'password123', + }); + + try { + await Parse.User.logIn('audituser2', 'wrongpassword'); + fail('Expected login to fail with wrong password'); + } catch (error) { + // Verify this is the expected authentication failure + expect(error.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toContain('Invalid username/password'); + } + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('USER_LOGIN'); + expect(logContent).toContain('"success":false'); + }); + + it('should log loginAs with master key', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'audituser3', + password: 'password123', + }); + + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/loginAs', + body: { + userId: user.id, + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + expect(response.data.sessionToken).toBeDefined(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('USER_LOGIN'); + expect(logContent).toContain('masterkey'); + expect(logContent).toContain('"success":true'); + }); + + it('should capture IP address in login logs', async () => { + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'audituser4', + password: 'password123', + }); + + await Parse.User.logOut(); + await Parse.User.logIn('audituser4', 'password123'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('ipAddress'); + }); + + it('should not log when audit logging is disabled', async () => { + await reconfigureServer({}); + + const user = new Parse.User(); + await user.signUp({ + username: 'audituser5', + password: 'password123', + }); + + await Parse.User.logOut(); + await Parse.User.logIn('audituser5', 'password123'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(fs.existsSync(testLogFolder)).toBe(false); + }); +}); diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index 10558b209d..9847360ccb 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -2172,3 +2172,219 @@ describe('Parse.Object testing', () => { } }); }); + +describe('Audit Logging - CRUD Operations', () => { + const fs = require('fs'); + const path = require('path'); + const testLogFolder = path.join(__dirname, 'temp-audit-logs-crud'); + + beforeEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + }); + + afterEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + it('should log object creation', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'cruduser1', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditCRUD'); + const obj = new TestClass(); + obj.set('name', 'created object'); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('DATA_CREATE'); + expect(logContent).toContain('AuditCRUD'); + expect(logContent).toContain(obj.id); + }); + + it('should log object update', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'cruduser2', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditCRUDUpdate'); + const obj = new TestClass(); + obj.set('name', 'original'); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + const logFiles1 = fs.readdirSync(testLogFolder); + if (logFiles1.length > 0) { + fs.unlinkSync(path.join(testLogFolder, logFiles1[0])); + } + + obj.set('name', 'updated'); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('DATA_UPDATE'); + expect(logContent).toContain('AuditCRUDUpdate'); + expect(logContent).toContain(obj.id); + }); + + it('should log object deletion', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'cruduser3', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditCRUDDelete'); + const obj = new TestClass(); + obj.set('name', 'to be deleted'); + await obj.save(); + + const objectId = obj.id; + + await new Promise(resolve => setTimeout(resolve, 200)); + const logFiles1 = fs.readdirSync(testLogFolder); + if (logFiles1.length > 0) { + fs.unlinkSync(path.join(testLogFolder, logFiles1[0])); + } + + await obj.destroy(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('DATA_DELETE'); + expect(logContent).toContain('AuditCRUDDelete'); + expect(logContent).toContain(objectId); + }); + + it('should log ACL modifications', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'cruduser4', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditCRUDACL'); + const obj = new TestClass(); + obj.set('name', 'with acl'); + const acl = new Parse.ACL(user); + obj.setACL(acl); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + const logFiles1 = fs.readdirSync(testLogFolder); + if (logFiles1.length > 0) { + fs.unlinkSync(path.join(testLogFolder, logFiles1[0])); + } + + const newAcl = new Parse.ACL(user); + newAcl.setPublicReadAccess(true); + obj.setACL(newAcl); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('ACL_MODIFY'); + expect(logContent).toContain('AuditCRUDACL'); + }); + + it('should mask sensitive fields in create logs', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'cruduser5', + password: 'password123', + }); + + const newUser = new Parse.User(); + await newUser.signUp({ + username: 'cruduser5sub', + password: 'secretpassword123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('***masked***'); + expect(logContent).not.toContain('secretpassword123'); + }); + + it('should capture user ID in CRUD logs', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'cruduser6', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditCRUDUserID'); + const obj = new TestClass(); + obj.set('name', 'test'); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('userId'); + expect(logContent).toContain(user.id); + }); + + it('should not log internal master key operations without user', async () => { + const TestClass = Parse.Object.extend('AuditCRUDInternal'); + const obj = new TestClass(); + obj.set('name', 'internal'); + await obj.save(null, { useMasterKey: true }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + if (logFiles.length > 0) { + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + const internalLogs = logContent.split('\n').filter(line => line.includes('AuditCRUDInternal')); + expect(internalLogs.length).toBe(0); + } + }); +}); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 0e4039979a..bf53787e15 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -5374,4 +5374,196 @@ describe('Parse.Query testing', () => { expect(query1.length).toEqual(1); }); }); + + describe('Audit Logging - Data View', () => { + const fs = require('fs'); + const path = require('path'); + const testLogFolder = path.join(__dirname, 'temp-audit-logs-query'); + + beforeEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + + await reconfigureServer({ + auditLog: { + auditLogFolder: testLogFolder, + }, + }); + }); + + afterEach(async () => { + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + it('should log data view when querying objects', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'queryuser1', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditTest'); + const obj1 = new TestClass(); + obj1.set('name', 'test1'); + await obj1.save(); + + const query = new Parse.Query('AuditTest'); + const results = await query.find(); + expect(results.length).toBeGreaterThan(0); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('DATA_VIEW'); + expect(logContent).toContain('AuditTest'); + expect(logContent).toContain(obj1.id); + }); + + it('should log data view with result count', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'queryuser2', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditTestMulti'); + const objects = []; + for (let i = 0; i < 5; i++) { + const obj = new TestClass(); + obj.set('index', i); + objects.push(obj); + } + await Parse.Object.saveAll(objects); + + const query = new Parse.Query('AuditTestMulti'); + const results = await query.find(); + expect(results.length).toBe(5); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('DATA_VIEW'); + expect(logContent).toContain('resultCount'); + expect(logContent).toContain('5'); + }); + + it('should log data view with query conditions', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'queryuser3', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditTestQuery'); + const obj = new TestClass(); + obj.set('name', 'searchable'); + await obj.save(); + + const query = new Parse.Query('AuditTestQuery'); + query.equalTo('name', 'searchable'); + const results = await query.find(); + expect(results.length).toBe(1); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('DATA_VIEW'); + expect(logContent).toContain('name'); + }); + + it('should not log empty query results', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'queryuser4', + password: 'password123', + }); + + const query = new Parse.Query('NonExistentClass'); + const results = await query.find(); + expect(results.length).toBe(0); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + const dataViewCount = (logContent.match(/DATA_VIEW/g) || []).length; + expect(dataViewCount).toBeLessThan(2); + }); + + it('should capture user ID in query logs', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'queryuser5', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditTestUser'); + const obj = new TestClass(); + await obj.save(); + + const query = new Parse.Query('AuditTestUser'); + await query.find(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + expect(logContent).toContain('userId'); + expect(logContent).toContain(user.id); + }); + + it('should limit object IDs in log to 100', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'queryuser6', + password: 'password123', + }); + + const TestClass = Parse.Object.extend('AuditTestLimit'); + const objects = []; + for (let i = 0; i < 150; i++) { + const obj = new TestClass(); + obj.set('index', i); + objects.push(obj); + } + await Parse.Object.saveAll(objects); + + const query = new Parse.Query('AuditTestLimit'); + query.limit(150); + const results = await query.find(); + expect(results.length).toBe(150); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = fs.readdirSync(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + + const logLines = logContent.split('\n').filter(line => line.includes('DATA_VIEW')); + expect(logLines.length).toBeGreaterThan(0); + + const logEntry = JSON.parse(logLines[0]); + if (logEntry.details && logEntry.details.objectIds) { + expect(logEntry.details.objectIds.length).toBeLessThanOrEqual(100); + } + }); + }); }); diff --git a/src/Adapters/Logger/AuditLogAdapter.js b/src/Adapters/Logger/AuditLogAdapter.js new file mode 100644 index 0000000000..648b347aba --- /dev/null +++ b/src/Adapters/Logger/AuditLogAdapter.js @@ -0,0 +1,295 @@ +import { LoggerAdapter } from './LoggerAdapter'; +import { configureAuditLogger, logAuditEvent, isAuditLogEnabled } from './AuditLogger'; + +/** + * AuditLogAdapter + * Adapter for GDPR-compliant audit logging + * Logs all data access and manipulation events to separate audit log files + */ +export class AuditLogAdapter extends LoggerAdapter { + constructor(options) { + super(); + if (options && options.auditLogFolder) { + configureAuditLogger(options); + } + } + + /** + * Standard log method (delegates to audit logger) + * @param {string} level - Log level + * @param {string} message - Log message + * @param {Object} meta - Metadata + */ + log(level, message, meta) { + if (!isAuditLogEnabled()) { + return; + } + // For standard log calls, just pass through + logAuditEvent({ + eventType: 'SYSTEM', + action: message, + details: meta, + }); + } + + /** + * Log user login event + * @param {Object} event - Login event details + * @param {string} event.userId - User ID + * @param {string} event.username - Username + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {boolean} event.success - Whether login succeeded + * @param {string} event.error - Error message if failed + * @param {string} event.loginMethod - Method used (password, oauth, etc.) + */ + logUserLogin(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'USER_LOGIN', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + action: `User login: ${event.username || event.userId}`, + details: { + username: event.username, + loginMethod: event.loginMethod || 'password', + }, + success: event.success, + error: event.error, + }); + } + + /** + * Log data view (read) event + * @param {Object} event - View event details + * @param {string} event.userId - User ID performing the query + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {string} event.className - Class being queried + * @param {Object} event.query - Query parameters + * @param {number} event.resultCount - Number of results returned + * @param {Array} event.objectIds - Object IDs accessed + */ + logDataView(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'DATA_VIEW', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + className: event.className, + action: `Query on ${event.className}`, + details: { + query: event.query, + resultCount: event.resultCount, + objectIds: event.objectIds, + }, + success: true, + }); + } + + /** + * Log data creation event + * @param {Object} event - Create event details + * @param {string} event.userId - User ID + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {string} event.className - Class name + * @param {string} event.objectId - Created object ID + * @param {Object} event.data - Data created (sensitive fields masked) + * @param {boolean} event.success - Whether creation succeeded + * @param {string} event.error - Error message if failed + */ + logDataCreate(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'DATA_CREATE', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + className: event.className, + objectId: event.objectId, + action: `Created object in ${event.className}`, + details: { + data: event.data, + }, + success: event.success, + error: event.error, + }); + } + + /** + * Log data update event + * @param {Object} event - Update event details + * @param {string} event.userId - User ID + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {string} event.className - Class name + * @param {string} event.objectId - Updated object ID + * @param {Object} event.updatedFields - Fields that were updated + * @param {boolean} event.success - Whether update succeeded + * @param {string} event.error - Error message if failed + */ + logDataUpdate(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'DATA_UPDATE', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + className: event.className, + objectId: event.objectId, + action: `Updated object in ${event.className}`, + details: { + updatedFields: event.updatedFields, + }, + success: event.success, + error: event.error, + }); + } + + /** + * Log data deletion event + * @param {Object} event - Delete event details + * @param {string} event.userId - User ID + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {string} event.className - Class name + * @param {string} event.objectId - Deleted object ID + * @param {boolean} event.success - Whether deletion succeeded + * @param {string} event.error - Error message if failed + */ + logDataDelete(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'DATA_DELETE', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + className: event.className, + objectId: event.objectId, + action: `Deleted object from ${event.className}`, + success: event.success, + error: event.error, + }); + } + + /** + * Log ACL modification event + * @param {Object} event - ACL modification event details + * @param {string} event.userId - User ID + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {string} event.className - Class name + * @param {string} event.objectId - Object ID + * @param {Object} event.oldACL - Previous ACL + * @param {Object} event.newACL - New ACL + * @param {boolean} event.success - Whether modification succeeded + * @param {string} event.error - Error message if failed + */ + logACLModify(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'ACL_MODIFY', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + className: event.className, + objectId: event.objectId, + action: `Modified ACL for object in ${event.className}`, + details: { + oldACL: event.oldACL, + newACL: event.newACL, + }, + success: event.success, + error: event.error, + }); + } + + /** + * Log schema modification event + * @param {Object} event - Schema modification event details + * @param {string} event.userId - User ID + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {string} event.className - Class name + * @param {string} event.operation - Operation (create, update, delete) + * @param {Object} event.changes - Schema changes + * @param {boolean} event.success - Whether modification succeeded + * @param {string} event.error - Error message if failed + */ + logSchemaModify(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'SCHEMA_MODIFY', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + className: event.className, + action: `Schema ${event.operation} for ${event.className}`, + details: { + operation: event.operation, + changes: event.changes, + }, + success: event.success, + error: event.error, + }); + } + + /** + * Log push notification event + * @param {Object} event - Push notification event details + * @param {string} event.userId - User ID + * @param {string} event.sessionToken - Session token + * @param {string} event.ipAddress - IP address + * @param {Object} event.query - Target query + * @param {Array} event.channels - Target channels + * @param {number} event.targetCount - Number of devices targeted + * @param {boolean} event.success - Whether push succeeded + * @param {string} event.error - Error message if failed + */ + logPushSend(event) { + if (!isAuditLogEnabled()) { + return; + } + logAuditEvent({ + eventType: 'PUSH_SEND', + userId: event.userId, + sessionToken: event.sessionToken, + ipAddress: event.ipAddress, + action: 'Push notification sent', + details: { + query: event.query, + channels: event.channels, + targetCount: event.targetCount, + }, + success: event.success, + error: event.error, + }); + } + + /** + * Check if audit logging is enabled + * @returns {boolean} True if audit logging is enabled + */ + isEnabled() { + return isAuditLogEnabled(); + } +} + +export default AuditLogAdapter; diff --git a/src/Adapters/Logger/AuditLogger.js b/src/Adapters/Logger/AuditLogger.js new file mode 100644 index 0000000000..e51a3d569d --- /dev/null +++ b/src/Adapters/Logger/AuditLogger.js @@ -0,0 +1,181 @@ +import winston, { format } from 'winston'; +import fs from 'fs'; +import path from 'path'; +import DailyRotateFile from 'winston-daily-rotate-file'; + +const auditLogger = winston.createLogger(); + +/** + * Configure the audit logger with daily rotation + * @param {Object} options - Configuration options for audit logging + * @param {string} options.dirname - Directory for audit log files + * @param {string} options.datePattern - Date pattern for log rotation (default: 'YYYY-MM-DD') + * @param {string} options.maxSize - Maximum size per log file (default: '20m') + * @param {string} options.maxFiles - Maximum number of log files to retain (default: '14d') + */ +function configureAuditTransports(options) { + const transports = []; + + if (!options || !options.dirname) { + // If no directory specified, audit logging is disabled + return; + } + + try { + const auditLogTransport = new DailyRotateFile({ + dirname: options.dirname, + filename: 'parse-server-audit-%DATE%.log', + datePattern: options.datePattern || 'YYYY-MM-DD', + maxSize: options.maxSize || '20m', + maxFiles: options.maxFiles || '14d', + json: true, + format: format.combine( + format.timestamp(), + format.json() + ), + }); + + auditLogTransport.name = 'parse-server-audit'; + transports.push(auditLogTransport); + + auditLogger.configure({ + transports, + level: 'info', + }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to configure audit logger:', e); + } +} + +/** + * Configure the audit logger + * @param {Object} config - Audit log configuration + * @param {string} config.auditLogFolder - Directory for audit logs + * @param {string} config.datePattern - Date pattern for rotation + * @param {string} config.maxSize - Maximum size per file + * @param {string} config.maxFiles - Maximum files to retain + */ +export function configureAuditLogger({ + auditLogFolder, + datePattern, + maxSize, + maxFiles, +} = {}) { + if (!auditLogFolder) { + // Audit logging disabled - close and remove any existing transports + try { + if (auditLogger.transports && auditLogger.transports.length > 0) { + // Close all transports + auditLogger.transports.forEach(transport => { + try { + if (transport.close) { + transport.close(); + } + } catch (err) { + // Ignore errors during transport cleanup + } + }); + // Clear all transports + auditLogger.clear(); + } + } catch (err) { + // Ignore errors during cleanup + } + return; + } + + let logFolder = auditLogFolder; + if (!path.isAbsolute(logFolder)) { + logFolder = path.resolve(process.cwd(), logFolder); + } + + try { + fs.mkdirSync(logFolder, { recursive: true }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to create audit log folder:', e); + // Clean up existing transports since audit logging cannot be enabled + try { + if (auditLogger.transports && auditLogger.transports.length > 0) { + auditLogger.transports.forEach(transport => { + try { + if (transport.close) { + transport.close(); + } + } catch (err) { + // Ignore errors during transport cleanup + } + }); + auditLogger.clear(); + } + } catch (err) { + // Ignore errors during cleanup + } + return; + } + + const options = { + dirname: logFolder, + datePattern, + maxSize, + maxFiles, + }; + + configureAuditTransports(options); +} + +/** + * Log an audit event + * @param {Object} event - Audit event object + * @param {string} event.eventType - Type of event (USER_LOGIN, DATA_VIEW, etc.) + * @param {string} event.userId - User ID performing the action + * @param {string} event.sessionToken - Session token (will be masked) + * @param {string} event.ipAddress - IP address of the request + * @param {string} event.className - Parse class name affected + * @param {string} event.objectId - Object ID affected + * @param {string} event.action - Description of the action + * @param {Object} event.details - Additional context-specific details + * @param {boolean} event.success - Whether the operation succeeded + * @param {string} event.error - Error message if operation failed + */ +export function logAuditEvent(event) { + if (!auditLogger.transports || auditLogger.transports.length === 0) { + // Audit logging is disabled + return; + } + + const auditEntry = { + timestamp: new Date().toISOString(), + eventType: event.eventType, + userId: event.userId || 'anonymous', + sessionToken: event.sessionToken ? '***masked***' : undefined, + ipAddress: event.ipAddress, + className: event.className, + objectId: event.objectId, + action: event.action, + details: event.details, + success: event.success !== false, // Default to true if not specified + error: event.error, + }; + + // Remove undefined fields + Object.keys(auditEntry).forEach(key => { + if (auditEntry[key] === undefined) { + delete auditEntry[key]; + } + }); + + auditLogger.info('audit_event', auditEntry); +} + +/** + * Check if audit logging is enabled + * @returns {boolean} True if audit logging is enabled + */ +export function isAuditLogEnabled() { + return auditLogger.transports && auditLogger.transports.length > 0; +} + +export { auditLogger }; +export default auditLogger; diff --git a/src/Controllers/AuditLogController.js b/src/Controllers/AuditLogController.js new file mode 100644 index 0000000000..f68457c0b4 --- /dev/null +++ b/src/Controllers/AuditLogController.js @@ -0,0 +1,318 @@ +import { AuditLogAdapter } from '../Adapters/Logger/AuditLogAdapter'; + +/** + * AuditLogController + * Controller for managing GDPR-compliant audit logging + */ +export class AuditLogController { + constructor(adapter, appId, options = {}) { + this.adapter = adapter; + this.appId = appId; + this.options = options; + } + + /** + * Get IP address from request + * @param {Object} req - Express request object + * @returns {string} IP address + * @private + */ + _getIPAddress(req) { + if (!req) { + return undefined; + } + // Check for X-Forwarded-For header (common in proxied environments) + const forwardedFor = req.headers && req.headers['x-forwarded-for']; + if (forwardedFor) { + // X-Forwarded-For can contain multiple IPs, take the first one + return forwardedFor.split(',')[0].trim(); + } + // Check for X-Real-IP header + const realIP = req.headers && req.headers['x-real-ip']; + if (realIP) { + return realIP; + } + // Fallback to connection remote address + return req.ip || req.connection?.remoteAddress; + } + + /** + * Extract user context from auth object + * @param {Object} auth - Auth object + * @returns {Object} User context + * @private + */ + _getUserContext(auth) { + if (!auth) { + return { + userId: undefined, + sessionToken: undefined, + }; + } + return { + userId: auth.user?.id || auth.user?.objectId, + sessionToken: auth.sessionToken, + }; + } + + /** + * Log user login event + * @param {Object} params - Login parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {string} params.username - Username + * @param {boolean} params.success - Whether login succeeded + * @param {string} params.error - Error message if failed + * @param {string} params.loginMethod - Login method used + */ + logUserLogin({ auth, req, username, success, error, loginMethod }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logUserLogin({ + userId: userContext.userId, + username: username, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + success: success, + error: error, + loginMethod: loginMethod, + }); + } + + /** + * Log data view (read) event + * @param {Object} params - View parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {string} params.className - Class being queried + * @param {Object} params.query - Query parameters + * @param {number} params.resultCount - Number of results + * @param {Array} params.objectIds - Object IDs accessed + */ + logDataView({ auth, req, className, query, resultCount, objectIds }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logDataView({ + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + className: className, + query: query, + resultCount: resultCount, + objectIds: objectIds, + }); + } + + /** + * Log data creation event + * @param {Object} params - Create parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {string} params.className - Class name + * @param {string} params.objectId - Created object ID + * @param {Object} params.data - Data created + * @param {boolean} params.success - Whether creation succeeded + * @param {string} params.error - Error message if failed + */ + logDataCreate({ auth, req, className, objectId, data, success, error }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logDataCreate({ + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + className: className, + objectId: objectId, + data: this._maskSensitiveData(data), + success: success, + error: error, + }); + } + + /** + * Log data update event + * @param {Object} params - Update parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {string} params.className - Class name + * @param {string} params.objectId - Updated object ID + * @param {Object} params.updatedFields - Fields that were updated + * @param {boolean} params.success - Whether update succeeded + * @param {string} params.error - Error message if failed + */ + logDataUpdate({ auth, req, className, objectId, updatedFields, success, error }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logDataUpdate({ + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + className: className, + objectId: objectId, + updatedFields: this._maskSensitiveData(updatedFields), + success: success, + error: error, + }); + } + + /** + * Log data deletion event + * @param {Object} params - Delete parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {string} params.className - Class name + * @param {string} params.objectId - Deleted object ID + * @param {boolean} params.success - Whether deletion succeeded + * @param {string} params.error - Error message if failed + */ + logDataDelete({ auth, req, className, objectId, success, error }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logDataDelete({ + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + className: className, + objectId: objectId, + success: success, + error: error, + }); + } + + /** + * Log ACL modification event + * @param {Object} params - ACL modification parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {string} params.className - Class name + * @param {string} params.objectId - Object ID + * @param {Object} params.oldACL - Previous ACL + * @param {Object} params.newACL - New ACL + * @param {boolean} params.success - Whether modification succeeded + * @param {string} params.error - Error message if failed + */ + logACLModify({ auth, req, className, objectId, oldACL, newACL, success, error }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logACLModify({ + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + className: className, + objectId: objectId, + oldACL: oldACL, + newACL: newACL, + success: success, + error: error, + }); + } + + /** + * Log schema modification event + * @param {Object} params - Schema modification parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {string} params.className - Class name + * @param {string} params.operation - Operation (create, update, delete) + * @param {Object} params.changes - Schema changes + * @param {boolean} params.success - Whether modification succeeded + * @param {string} params.error - Error message if failed + */ + logSchemaModify({ auth, req, className, operation, changes, success, error }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logSchemaModify({ + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + className: className, + operation: operation, + changes: changes, + success: success, + error: error, + }); + } + + /** + * Log push notification event + * @param {Object} params - Push notification parameters + * @param {Object} params.auth - Auth object + * @param {Object} params.req - Request object + * @param {Object} params.query - Target query + * @param {Array} params.channels - Target channels + * @param {number} params.targetCount - Number of devices targeted + * @param {boolean} params.success - Whether push succeeded + * @param {string} params.error - Error message if failed + */ + logPushSend({ auth, req, query, channels, targetCount, success, error }) { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + const userContext = this._getUserContext(auth); + this.adapter.logPushSend({ + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ipAddress: this._getIPAddress(req), + query: query, + channels: channels, + targetCount: targetCount, + success: success, + error: error, + }); + } + + /** + * Mask sensitive data (passwords, tokens, etc.) + * @param {Object} data - Data to mask + * @returns {Object} Masked data + * @private + */ + _maskSensitiveData(data) { + if (!data || typeof data !== 'object') { + return data; + } + + const masked = { ...data }; + const sensitiveFields = ['password', 'sessionToken', 'authData', '_hashed_password']; + + sensitiveFields.forEach(field => { + if (masked[field]) { + masked[field] = '***masked***'; + } + }); + + return masked; + } + + /** + * Check if audit logging is enabled + * @returns {boolean} True if enabled + */ + isEnabled() { + return !!(this.adapter && this.adapter.isEnabled()); + } +} + +export default AuditLogController; diff --git a/src/Controllers/index.js b/src/Controllers/index.js index abf0950640..4689cc0ff2 100644 --- a/src/Controllers/index.js +++ b/src/Controllers/index.js @@ -4,6 +4,7 @@ import { loadAdapter, loadModule } from '../Adapters/AdapterLoader'; import defaults from '../defaults'; // Controllers import { LoggerController } from './LoggerController'; +import { AuditLogController } from './AuditLogController'; import { FilesController } from './FilesController'; import { HooksController } from './HooksController'; import { UserController } from './UserController'; @@ -18,6 +19,7 @@ import DatabaseController from './DatabaseController'; // Adapters import { GridFSBucketAdapter } from '../Adapters/Files/GridFSBucketAdapter'; import { WinstonLoggerAdapter } from '../Adapters/Logger/WinstonLoggerAdapter'; +import { AuditLogAdapter } from '../Adapters/Logger/AuditLogAdapter'; import { InMemoryCacheAdapter } from '../Adapters/Cache/InMemoryCacheAdapter'; import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; @@ -27,6 +29,7 @@ import SchemaCache from '../Adapters/Cache/SchemaCache'; export function getControllers(options: ParseServerOptions) { const loggerController = getLoggerController(options); + const auditLogController = getAuditLogController(options); const filesController = getFilesController(options); const userController = getUserController(options); const cacheController = getCacheController(options); @@ -41,6 +44,7 @@ export function getControllers(options: ParseServerOptions) { }); return { loggerController, + auditLogController, filesController, userController, analyticsController, @@ -77,6 +81,25 @@ export function getLoggerController(options: ParseServerOptions): LoggerControll return new LoggerController(loggerControllerAdapter, appId, loggerOptions); } +export function getAuditLogController(options: ParseServerOptions): AuditLogController { + const { appId, auditLog } = options; + + if (!auditLog || !auditLog.auditLogFolder) { + // Audit logging is disabled, return a controller with no adapter + return new AuditLogController(null, appId, {}); + } + + const auditLogOptions = { + auditLogFolder: auditLog.auditLogFolder, + datePattern: auditLog.datePattern, + maxSize: auditLog.maxSize, + maxFiles: auditLog.maxFiles, + }; + + const auditLogAdapter = new AuditLogAdapter(auditLogOptions); + return new AuditLogController(auditLogAdapter, appId, auditLogOptions); +} + export function getFilesController(options: ParseServerOptions): FilesController { const { appId, diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index a23a0de3e5..30859f3224 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -105,6 +105,14 @@ module.exports.ParseServerOptions = { help: 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', }, + auditLog: { + env: 'PARSE_SERVER_AUDIT_LOG', + help: + 'Configuration for GDPR-compliant audit logging. Logs user login, data access, data manipulation, schema changes, ACL changes, and push notifications.', + action: parsers.objectParser, + type: 'AuditLogOptions', + default: {}, + }, cacheAdapter: { env: 'PARSE_SERVER_CACHE_ADAPTER', help: 'Adapter module for the cache', @@ -978,6 +986,27 @@ module.exports.AccountLockoutOptions = { default: false, }, }; +module.exports.AuditLogOptions = { + auditLogFolder: { + env: 'PARSE_SERVER_AUDIT_LOG_FOLDER', + help: 'Folder path where audit logs will be stored. If not set, audit logging is disabled.', + }, + datePattern: { + env: 'PARSE_SERVER_AUDIT_LOG_DATE_PATTERN', + help: 'Date pattern for log file rotation. Default is \'YYYY-MM-DD\' (daily rotation).', + default: 'YYYY-MM-DD', + }, + maxSize: { + env: 'PARSE_SERVER_AUDIT_LOG_MAX_SIZE', + help: 'Maximum size of each log file (e.g., \'20m\', \'1g\'). Default is \'20m\'.', + default: '20m', + }, + maxFiles: { + env: 'PARSE_SERVER_AUDIT_LOG_MAX_FILES', + help: 'Maximum number of log files to retain (e.g., \'14d\', \'10\'). Default is \'14d\' (14 days).', + default: '14d', + }, +}; module.exports.PasswordPolicyOptions = { doNotAllowUsername: { env: 'PARSE_SERVER_PASSWORD_POLICY_DO_NOT_ALLOW_USERNAME', diff --git a/src/Options/index.js b/src/Options/index.js index b1827d808a..2385496bc8 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -211,6 +211,10 @@ export interface ParseServerOptions { sendUserEmailVerification: ?(boolean | void); /* The account lockout policy for failed login attempts. */ accountLockout: ?AccountLockoutOptions; + /* Configuration for GDPR-compliant audit logging. Logs user login, data access, data manipulation, schema changes, ACL changes, and push notifications. + :ENV: PARSE_SERVER_AUDIT_LOG + :DEFAULT: {} */ + auditLog: ?AuditLogOptions; /* The password policy for enforcing password related rules. */ passwordPolicy: ?PasswordPolicyOptions; /* Adapter module for the cache */ @@ -538,6 +542,26 @@ export interface AccountLockoutOptions { unlockOnPasswordReset: ?boolean; } +export interface AuditLogOptions { + /* Folder path where audit logs will be stored. If not set, audit logging is disabled. */ + auditLogFolder: ?string; + /* Date pattern for log file rotation. +

+ Default is 'YYYY-MM-DD' (daily rotation). + :DEFAULT: YYYY-MM-DD */ + datePattern: ?string; + /* Maximum size of each log file (e.g., '20m', '1g'). +

+ Default is '20m'. + :DEFAULT: 20m */ + maxSize: ?string; + /* Maximum number of log files to retain (e.g., '14d', '10'). +

+ Default is '14d' (14 days). + :DEFAULT: 14d */ + maxFiles: ?string; +} + export interface PasswordPolicyOptions { /* Set the regular expression validation pattern a password must match to be accepted.

diff --git a/src/RestQuery.js b/src/RestQuery.js index 621700984b..59fac449d2 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -302,6 +302,9 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.handleAuthAdapters(); }) + .then(() => { + return this.logAuditDataView(); + }) .then(() => { return this.response; }); @@ -946,6 +949,41 @@ _UnsafeRestQuery.prototype.handleAuthAdapters = async function () { ); }; +_UnsafeRestQuery.prototype.logAuditDataView = function () { + if (!this.config.auditLogController || !this.config.auditLogController.isEnabled()) { + return Promise.resolve(); + } + + if (this.auth.isMaster && !this.auth.user) { + return Promise.resolve(); + } + + if (!this.response.results || this.response.results.length === 0) { + return Promise.resolve(); + } + + const objectIds = this.response.results + .map(result => result.objectId) + .filter(id => id !== undefined) + .slice(0, 100); + + try { + this.config.auditLogController.logDataView({ + auth: this.auth, + req: { config: this.config }, // Minimal req object since we don't have access to full request here + className: this.className, + query: this.restWhere, + resultCount: this.response.results.length, + objectIds: objectIds, + }); + } catch (error) { + // Don't fail the query if audit logging fails + this.config.loggerController.error('Audit logging error in RestQuery', { error }); + } + + return Promise.resolve(); +}; + // Adds included values to the response. // Path is a list of field names. // Returns a promise for an augmented response. diff --git a/src/RestWrite.js b/src/RestWrite.js index 78dd8c8878..5782c5fe73 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -154,6 +154,9 @@ RestWrite.prototype.execute = function () { .then(() => { return this.cleanUserAuthData(); }) + .then(() => { + return this.logAuditDataWrite(); + }) .then(() => { // Append the authDataResponse if exists if (this.authDataResponse) { @@ -1795,6 +1798,74 @@ RestWrite.prototype.cleanUserAuthData = function () { } }; +RestWrite.prototype.logAuditDataWrite = function () { + if (!this.config.auditLogController || !this.config.auditLogController.isEnabled()) { + return Promise.resolve(); + } + + // Skip only master key operations without a user context + // Maintenance mode operations should still be audited + if (this.auth.isMaster && !this.auth.user) { + return Promise.resolve(); + } + + if (!this.response || !this.response.response) { + return Promise.resolve(); + } + + const objectId = this.response.response.objectId; + const isCreate = !this.query; + const isUpdate = !!this.query; + + // Check if ACL was modified, including cases where ACL was added or removed + const originalACL = this.originalData?.ACL ?? null; + const newACL = this.data?.ACL ?? null; + const aclModified = isUpdate && JSON.stringify(originalACL) !== JSON.stringify(newACL); + + try { + if (isCreate) { + this.config.auditLogController.logDataCreate({ + auth: this.auth, + req: { config: this.config }, + className: this.className, + objectId: objectId, + data: this.data, + success: true, + }); + } else if (isUpdate) { + // Extract only the fields that were updated + const updatedFields = Object.keys(this.data); + + this.config.auditLogController.logDataUpdate({ + auth: this.auth, + req: { config: this.config }, + className: this.className, + objectId: objectId, + updatedFields: updatedFields, + success: true, + }); + } + + // If ACL was modified, log it separately + if (aclModified) { + this.config.auditLogController.logACLModify({ + auth: this.auth, + req: { config: this.config }, + className: this.className, + objectId: objectId, + oldACL: originalACL, + newACL: newACL, + success: true, + }); + } + } catch (error) { + // Don't fail the write if audit logging fails + this.config.loggerController.error('Audit logging error in RestWrite', { error }); + } + + return Promise.resolve(); +}; + RestWrite.prototype._updateResponseWithData = function (response, data) { const stateController = Parse.CoreManager.getObjectStateController(); const [pending] = stateController.getPendingOps(this.pendingOps.identifier); diff --git a/src/Routers/PushRouter.js b/src/Routers/PushRouter.js index 1c1c8f3b5f..fb991fb712 100644 --- a/src/Routers/PushRouter.js +++ b/src/Routers/PushRouter.js @@ -20,14 +20,31 @@ export class PushRouter extends PromiseRouter { } const where = PushRouter.getQueryCondition(req); + const body = req.body || {}; + const channels = body.channels; + let resolve; - const promise = new Promise(_resolve => { + let reject; + const promise = new Promise((_resolve, _reject) => { resolve = _resolve; + reject = _reject; }); let pushStatusId; pushController - .sendPush(req.body || {}, where, req.config, req.auth, objectId => { + .sendPush(body, where, req.config, req.auth, objectId => { pushStatusId = objectId; + + if (req.config.auditLogController) { + req.config.auditLogController.logPushSend({ + auth: req.auth, + req, + query: where, + channels: channels, + targetCount: undefined, + success: true, + }); + } + resolve({ headers: { 'X-Parse-Push-Status-Id': pushStatusId, @@ -42,6 +59,20 @@ export class PushRouter extends PromiseRouter { `_PushStatus ${pushStatusId}: error while sending push`, err ); + + if (req.config.auditLogController) { + req.config.auditLogController.logPushSend({ + auth: req.auth, + req, + query: where, + channels: channels, + targetCount: undefined, + success: false, + error: err.message, + }); + } + + reject(err); }); return promise; } diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 0a42123af7..7ea603e2f5 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -88,10 +88,38 @@ async function createSchema(req) { throw new Parse.Error(135, `POST ${req.path} needs a class name.`); } - return await internalCreateSchema(className, req.body || {}, req.config); + try { + const result = await internalCreateSchema(className, req.body || {}, req.config); + + if (req.config.auditLogController) { + req.config.auditLogController.logSchemaModify({ + auth: req.auth, + req, + className, + operation: 'create', + changes: req.body, + success: true, + }); + } + + return result; + } catch (error) { + if (req.config.auditLogController) { + req.config.auditLogController.logSchemaModify({ + auth: req.auth, + req, + className, + operation: 'create', + changes: req.body, + success: false, + error: error.message, + }); + } + throw error; + } } -function modifySchema(req) { +async function modifySchema(req) { checkIfDefinedSchemasIsUsed(req); if (req.auth.isReadOnly) { throw new Parse.Error( @@ -104,10 +132,38 @@ function modifySchema(req) { } const className = req.params.className; - return internalUpdateSchema(className, req.body || {}, req.config); + try { + const result = await internalUpdateSchema(className, req.body || {}, req.config); + + if (req.config.auditLogController) { + req.config.auditLogController.logSchemaModify({ + auth: req.auth, + req, + className, + operation: 'update', + changes: req.body, + success: true, + }); + } + + return result; + } catch (error) { + if (req.config.auditLogController) { + req.config.auditLogController.logSchemaModify({ + auth: req.auth, + req, + className, + operation: 'update', + changes: req.body, + success: false, + error: error.message, + }); + } + throw error; + } } -const deleteSchema = req => { +const deleteSchema = async req => { if (req.auth.isReadOnly) { throw new Parse.Error( Parse.Error.OPERATION_FORBIDDEN, @@ -120,7 +176,38 @@ const deleteSchema = req => { SchemaController.invalidClassNameMessage(req.params.className) ); } - return req.config.database.deleteSchema(req.params.className).then(() => ({ response: {} })); + + const className = req.params.className; + + try { + await req.config.database.deleteSchema(className); + + if (req.config.auditLogController) { + req.config.auditLogController.logSchemaModify({ + auth: req.auth, + req, + className, + operation: 'delete', + changes: {}, + success: true, + }); + } + + return { response: {} }; + } catch (error) { + if (req.config.auditLogController) { + req.config.auditLogController.logSchemaModify({ + auth: req.auth, + req, + className, + operation: 'delete', + changes: {}, + success: false, + error: error.message, + }); + } + throw error; + } }; export class SchemasRouter extends PromiseRouter { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 7668562965..8b392dc693 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -199,122 +199,146 @@ export class UsersRouter extends ClassesRouter { } async handleLogIn(req) { - const user = await this._authenticateUserFromRequest(req); - const authData = req.body && req.body.authData; - // Check if user has provided their required auth providers - Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( - req, - authData, - user.authData, - req.config - ); - - let authDataResponse; - let validatedAuthData; - if (authData) { - const res = await Auth.handleAuthDataValidation( + try { + const user = await this._authenticateUserFromRequest(req); + const authData = req.body && req.body.authData; + // Check if user has provided their required auth providers + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + req, authData, - new RestWrite( - req.config, - req.auth, - '_User', - { objectId: user.objectId }, - req.body || {}, - user, - req.info.clientSDK, - req.info.context - ), - user + user.authData, + req.config ); - authDataResponse = res.authDataResponse; - validatedAuthData = res.authData; - } - // handle password expiry policy - if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { - let changedAt = user._password_changed_at; + let authDataResponse; + let validatedAuthData; + if (authData) { + const res = await Auth.handleAuthDataValidation( + authData, + new RestWrite( + req.config, + req.auth, + '_User', + { objectId: user.objectId }, + req.body || {}, + user, + req.info.clientSDK, + req.info.context + ), + user + ); + authDataResponse = res.authDataResponse; + validatedAuthData = res.authData; + } - if (!changedAt) { + // handle password expiry policy + if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { + let changedAt = user._password_changed_at; + + if (!changedAt) { // password was created before expiry policy was enabled. // simply update _User object so that it will start enforcing from now - changedAt = new Date(); - req.config.database.update( - '_User', - { username: user.username }, - { _password_changed_at: Parse._encode(changedAt) } - ); - } else { + changedAt = new Date(); + req.config.database.update( + '_User', + { username: user.username }, + { _password_changed_at: Parse._encode(changedAt) } + ); + } else { // check whether the password has expired - if (changedAt.__type == 'Date') { - changedAt = new Date(changedAt.iso); + if (changedAt.__type == 'Date') { + changedAt = new Date(changedAt.iso); + } + // Calculate the expiry time. + const expiresAt = new Date( + changedAt.getTime() + 86400000 * req.config.passwordPolicy.maxPasswordAge + ); + if (expiresAt < new Date()) + // fail of current time is past password expiry time + { throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your password has expired. Please reset your password.' + ); } } - // Calculate the expiry time. - const expiresAt = new Date( - changedAt.getTime() + 86400000 * req.config.passwordPolicy.maxPasswordAge - ); - if (expiresAt < new Date()) - // fail of current time is past password expiry time - { throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Your password has expired. Please reset your password.' - ); } } - } - // Remove hidden properties. - UsersRouter.removeHiddenProperties(user); - - await req.config.filesController.expandFilesInObject(req.config, user); - - // Before login trigger; throws if failure - await maybeRunTrigger( - TriggerTypes.beforeLogin, - req.auth, - Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), - null, - req.config, - req.info.context - ); - - // If we have some new validated authData update directly - if (validatedAuthData && Object.keys(validatedAuthData).length) { - await req.config.database.update( - '_User', - { objectId: user.objectId }, - { authData: validatedAuthData }, - {} + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); + + await req.config.filesController.expandFilesInObject(req.config, user); + + // Before login trigger; throws if failure + await maybeRunTrigger( + TriggerTypes.beforeLogin, + req.auth, + Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), + null, + req.config, + req.info.context ); - } - const { sessionData, createSession } = RestWrite.createSession(req.config, { - userId: user.objectId, - createdWith: { - action: 'login', - authProvider: 'password', - }, - installationId: req.info.installationId, - }); + // If we have some new validated authData update directly + if (validatedAuthData && Object.keys(validatedAuthData).length) { + await req.config.database.update( + '_User', + { objectId: user.objectId }, + { authData: validatedAuthData }, + {} + ); + } - user.sessionToken = sessionData.sessionToken; + const { sessionData, createSession } = RestWrite.createSession(req.config, { + userId: user.objectId, + createdWith: { + action: 'login', + authProvider: 'password', + }, + installationId: req.info.installationId, + }); - await createSession(); + user.sessionToken = sessionData.sessionToken; - const afterLoginUser = Parse.User.fromJSON(Object.assign({ className: '_User' }, user)); - await maybeRunTrigger( - TriggerTypes.afterLogin, - { ...req.auth, user: afterLoginUser }, - afterLoginUser, - null, - req.config, - req.info.context - ); + await createSession(); - if (authDataResponse) { - user.authDataResponse = authDataResponse; - } - await req.config.authDataManager.runAfterFind(req, user.authData); + const afterLoginUser = Parse.User.fromJSON(Object.assign({ className: '_User' }, user)); + await maybeRunTrigger( + TriggerTypes.afterLogin, + { ...req.auth, user: afterLoginUser }, + afterLoginUser, + null, + req.config, + req.info.context + ); + + if (authDataResponse) { + user.authDataResponse = authDataResponse; + } + await req.config.authDataManager.runAfterFind(req, user.authData); + + if (req.config.auditLogController) { + req.config.auditLogController.logUserLogin({ + auth: { ...req.auth, user: afterLoginUser, sessionToken: user.sessionToken ? '***masked***' : undefined }, + req, + username: user.username || user.email, + success: true, + loginMethod: authData ? 'oauth' : 'password', + }); + } - return { response: user }; + return { response: user }; + } catch (error) { + if (req.config.auditLogController) { + req.config.auditLogController.logUserLogin({ + auth: req.auth, + req, + username: req.body?.username || req.body?.email || req.query?.username || req.query?.email, + success: false, + error: error.message, + loginMethod: req.body?.authData ? 'oauth' : 'password', + }); + } + throw error; + } } /** @@ -332,40 +356,65 @@ export class UsersRouter extends ClassesRouter { * different reasons from /login */ async handleLogInAs(req) { - if (!req.auth.isMaster) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'master key is required'); - } + try { + if (!req.auth.isMaster) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'master key is required'); + } - const userId = req.body?.userId || req.query.userId; - if (!userId) { - throw new Parse.Error( - Parse.Error.INVALID_VALUE, - 'userId must not be empty, null, or undefined' - ); - } + const userId = req.body?.userId || req.query.userId; + if (!userId) { + throw new Parse.Error( + Parse.Error.INVALID_VALUE, + 'userId must not be empty, null, or undefined' + ); + } - const queryResults = await req.config.database.find('_User', { objectId: userId }); - const user = queryResults[0]; - if (!user) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'user not found'); - } + const queryResults = await req.config.database.find('_User', { objectId: userId }); + const user = queryResults[0]; + if (!user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'user not found'); + } - this._sanitizeAuthData(user); + this._sanitizeAuthData(user); - const { sessionData, createSession } = RestWrite.createSession(req.config, { - userId, - createdWith: { - action: 'login', - authProvider: 'masterkey', - }, - installationId: req.info.installationId, - }); + const { sessionData, createSession } = RestWrite.createSession(req.config, { + userId, + createdWith: { + action: 'login', + authProvider: 'masterkey', + }, + installationId: req.info.installationId, + }); + + user.sessionToken = sessionData.sessionToken; - user.sessionToken = sessionData.sessionToken; + await createSession(); - await createSession(); + if (req.config.auditLogController) { + const afterLoginUser = Parse.User.fromJSON(Object.assign({ className: '_User' }, user)); + req.config.auditLogController.logUserLogin({ + auth: { ...req.auth, user: afterLoginUser, sessionToken: user.sessionToken ? '***masked***' : undefined }, + req, + username: user.username || user.email || userId, + success: true, + loginMethod: 'masterkey', + }); + } - return { response: user }; + return { response: user }; + } catch (error) { + if (req.config.auditLogController) { + req.config.auditLogController.logUserLogin({ + auth: req.auth, + req, + username: req.body?.userId || req.query.userId, + success: false, + error: error.message, + loginMethod: 'masterkey', + }); + } + throw error; + } } handleVerifyPassword(req) {