diff --git a/GDPR_COMPLIANCE_GUIDE.md b/GDPR_COMPLIANCE_GUIDE.md new file mode 100644 index 0000000000..5c23b1cb32 --- /dev/null +++ b/GDPR_COMPLIANCE_GUIDE.md @@ -0,0 +1,1043 @@ +# 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:** +- Helps you maintain records relevant to Article 30 (Records of Processing Activities) +- Provides audit trails that support Article 33 (Breach Notification) +- Provides evidence that can support Article 32 (Security of Processing) + +**Configuration:** + +**Basic Configuration (File-based logging):** +```javascript +new ParseServer({ + // ... other options + auditLog: { + adapter: 'winston-file', // Optional - default is 'winston-file' + adapterOptions: { + 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) + } + } +}); +``` + +**Advanced Configuration (with filtering):** +```javascript +new ParseServer({ + // ... other options + auditLog: { + adapter: 'winston-file', + adapterOptions: { + auditLogFolder: './audit-logs', + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '14d', + }, + logFilter: { + // Log only specific event types + events: ['USER_LOGIN', 'DATA_DELETE', 'SCHEMA_MODIFY'], + + // Log only specific Parse classes + includeClasses: ['_User', 'Order', 'Payment'], + + // Exclude certain classes from logging + excludeClasses: ['_Session', 'TempData'], + + // Exclude master key operations (optional) + excludeMasterKey: false, + + // Filter by user roles + includeRoles: ['admin', 'moderator'], + + // Custom filter function for advanced logic + filter: (event) => { + // Example: Don't log system user operations + return event.userId !== 'system'; + } + } + } +}); +``` + +**Custom Adapter (e.g., S3 storage):** +```javascript +import { MyS3AuditLogAdapter } from './adapters/MyS3AuditLogAdapter'; + +new ParseServer({ + // ... other options + auditLog: { + adapter: MyS3AuditLogAdapter, // Custom adapter instance + adapterOptions: { + bucket: 'my-audit-logs', + region: 'eu-west-1', + encryption: 'AES256', + }, + logFilter: { + events: ['USER_LOGIN', 'DATA_DELETE'], + } + } +}); +``` + +**Pluggable Adapter Architecture:** + +Parse Server's audit logging now uses a pluggable adapter pattern (similar to CacheAdapter, LoggerAdapter, etc.), allowing you to: + +- **File-based storage** (default): Winston with daily rotation +- **S3 storage**: Immutable logs via S3 bucket settings +- **Database storage**: Store in MongoDB/PostgreSQL for easy querying +- **External SIEM**: Forward to CloudWatch, Datadog, Splunk, etc. +- **Custom implementation**: Implement `AuditLogAdapterInterface` for your needs + +**Creating a Custom Adapter:** + +```javascript +// src/adapters/MyCustomAuditLogAdapter.js +import { AuditLogAdapterInterface } from 'parse-server/lib/Adapters/AuditLog/AuditLogAdapterInterface'; + +export class MyCustomAuditLogAdapter extends AuditLogAdapterInterface { + constructor(options) { + super(); + this.options = options; + // Initialize your storage backend + } + + isEnabled() { + return true; + } + + async logUserLogin(event) { + // Store login event to your backend + await this.store(event); + } + + async logDataView(event) { + await this.store(event); + } + + // ... implement other methods (logDataCreate, logDataUpdate, etc.) + + async store(event) { + // Your custom storage logic (S3, database, external service, etc.) + } +} +``` + +### 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'); + // Prefer null for email if schema allows; otherwise use unique non-deliverable placeholder + // to avoid unique constraint violations and prevent accidental messaging + order.set('userEmail', null); // Or: `deleted-${order.id}@example.invalid` for unique RFC-compliant placeholder + 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:** + +**Note on Tracking User Inactivity:** +Parse Server doesn't include a `lastLoginAt` field by default. You have two options: + +1. **Option A (Recommended): Query _Session class** - Works immediately, no setup required. Query sessions to determine last activity. Suitable for most use cases. +2. **Option B: Maintain custom lastLoginAt field** - Better performance for large user bases. Requires adding an `afterLogin` hook to update the field. + +The example below shows **Option A**. For **Option B**, see the alternative implementation at the end of this section. + +```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); + + // APPROACH 1: Query _Session to find last activity (works out of the box) + // Find all sessions created after the threshold to identify ACTIVE users + const activeSessions = await new Parse.Query('_Session') + .greaterThan('createdAt', inactiveThreshold) + .select('user') + .limit(10000) + .find({ useMasterKey: true }); + + const activeUserIds = new Set(activeSessions.map(s => s.get('user')?.id).filter(Boolean)); + + // Get all users and filter out the active ones + const allUsers = await new Parse.Query('_User') + .limit(10000) + .find({ useMasterKey: true }); + + const inactiveUsers = allUsers.filter(user => !activeUserIds.has(user.id)); + + 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'); + // Use unique non-deliverable placeholder to avoid unique constraint violations + order.set('userEmail', `anonymized-${order.id}@example.invalid`); + 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'); +}); +``` + +**Option B: Using Custom lastLoginAt Field** + +If you have a large user base and need better query performance, maintain a custom field: + +```javascript +// 1. Add an afterLogin hook to track login times +Parse.Cloud.afterLogin(async (request) => { + const user = request.user; + user.set('lastLoginAt', new Date()); + await user.save(null, { useMasterKey: true }); +}); + +// 2. Simplified retention job using the custom field +Parse.Cloud.job('enforceDataRetention', async (request) => { + const { message } = request; + + const inactiveThreshold = new Date(); + inactiveThreshold.setFullYear(inactiveThreshold.getFullYear() - 2); + + // Direct query on lastLoginAt field (requires the field to exist) + const inactiveUsers = await new Parse.Query('_User') + .lessThan('lastLoginAt', inactiveThreshold) + .limit(10000) + .find({ useMasterKey: true }); + + message(`Found ${inactiveUsers.length} inactive users`); + + for (const user of inactiveUsers) { + // Use your deletion logic... + } +}); +``` + +**Note:** With Option B, ensure `lastLoginAt` is set for all users before relying on it for retention policies. You may need a one-time migration to populate this field from existing session data. + +--- + +### 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/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 5b9084f863..0ca6dcd526 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -13,6 +13,8 @@ const parsers = require('../src/Options/parsers'); /** The types of nested options. */ const nestedOptionTypes = [ + 'AuditLogFilterOptions', + 'AuditLogOptions', 'CustomPagesOptions', 'DatabaseOptions', 'FileUploadOptions', @@ -25,11 +27,14 @@ const nestedOptionTypes = [ 'SecurityOptions', 'SchemaOptions', 'LogLevels', + 'WinstonFileAuditLogAdapterOptions', ]; /** The prefix of environment variables for nested options. */ const nestedOptionEnvPrefix = { AccountLockoutOptions: 'PARSE_SERVER_ACCOUNT_LOCKOUT_', + AuditLogOptions: 'PARSE_SERVER_AUDIT_LOG_', + AuditLogFilterOptions: 'PARSE_SERVER_AUDIT_LOG_FILTER_', CustomPagesOptions: 'PARSE_SERVER_CUSTOM_PAGES_', DatabaseOptions: 'PARSE_SERVER_DATABASE_', FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_', @@ -45,6 +50,7 @@ const nestedOptionEnvPrefix = { SchemaOptions: 'PARSE_SERVER_SCHEMA_', LogLevels: 'PARSE_SERVER_LOG_LEVELS_', RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_', + WinstonFileAuditLogAdapterOptions: 'PARSE_SERVER_AUDIT_LOG_', }; function last(array) { @@ -280,11 +286,13 @@ function inject(t, list) { if (elt.defaultValue) { let parsedValue = parseDefaultValue(elt, elt.defaultValue, t); if (!parsedValue) { - for (const type of elt.typeAnnotation.types) { - elt.type = type.type; - parsedValue = parseDefaultValue(elt, elt.defaultValue, t); - if (parsedValue) { - break; + if (elt.typeAnnotation && elt.typeAnnotation.types && Array.isArray(elt.typeAnnotation.types)) { + for (const type of elt.typeAnnotation.types) { + elt.type = type.type; + parsedValue = parseDefaultValue(elt, elt.defaultValue, t); + if (parsedValue) { + break; + } } } } diff --git a/spec/AuditLogAdapter.spec.js b/spec/AuditLogAdapter.spec.js new file mode 100644 index 0000000000..68f5d54a6b --- /dev/null +++ b/spec/AuditLogAdapter.spec.js @@ -0,0 +1,533 @@ +'use strict'; + +const { WinstonFileAuditLogAdapter } = require('../lib/Adapters/AuditLog/WinstonFileAuditLogAdapter'); +const fs = require('fs'); +const path = require('path'); + +describe('AuditLogAdapter', () => { + const testLogFolder = path.join(__dirname, 'temp-audit-logs'); + const getLogFiles = (folder) => fs.readdirSync(folder).filter(f => f.endsWith('.log')); + const testAppId = 'testApp123'; + + let adapter; + + beforeEach(() => { + // Clean up test log folder + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + adapter = null; + }); + + afterEach(async () => { + // Close adapter before deleting files + if (adapter && adapter.close) { + await adapter.close(); + } + // Give Winston time to close file handles + await new Promise(resolve => setTimeout(resolve, 100)); + if (fs.existsSync(testLogFolder)) { + fs.rmSync(testLogFolder, { recursive: true, force: true }); + } + }); + + describe('constructor', () => { + it('should initialize without options', () => { + adapter = new WinstonFileAuditLogAdapter({}); + expect(adapter).toBeDefined(); + expect(adapter.isEnabled()).toBe(false); + }); + + it('should initialize with auditLogFolder option', () => { + adapter = new WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + expect(adapter).toBeDefined(); + expect(fs.existsSync(testLogFolder)).toBe(true); + expect(adapter.isEnabled()).toBe(true); + }); + + it('should not create folder without auditLogFolder option', () => { + adapter = new WinstonFileAuditLogAdapter({}); + expect(adapter).toBeDefined(); + expect(fs.existsSync(testLogFolder)).toBe(false); + expect(adapter.isEnabled()).toBe(false); + }); + }); + + describe('isEnabled', () => { + it('should return false when not configured', () => { + const adapter = new WinstonFileAuditLogAdapter(); + expect(adapter.isEnabled()).toBe(false); + }); + + it('should return true when configured with folder', () => { + const adapter = new WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + expect(adapter.isEnabled()).toBe(true); + }); + }); + + describe('logSystemEvent', () => { + it('should not throw when audit logging is disabled', () => { + const adapter = new WinstonFileAuditLogAdapter(); + expect(() => { + adapter.logSystemEvent({ + eventType: 'SYSTEM', + timestamp: new Date().toISOString(), + appId: testAppId, + success: true, + }); + }).not.toThrow(); + }); + + it('should log system event when enabled', done => { + const adapter = new WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logSystemEvent({ + eventType: 'SYSTEM', + timestamp: new Date().toISOString(), + appId: testAppId, + success: true, + }); + + // Give winston time to write + setTimeout(() => { + const logFiles = getLogFiles(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + done(); + }, 100); + }); + }); + + describe('logUserLogin', () => { + it('should not throw when disabled', () => { + const adapter = new WinstonFileAuditLogAdapter(); + expect(() => { + adapter.logUserLogin({ + eventType: 'USER_LOGIN', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + username: 'testuser', + sessionToken: 'token123', + ip: '127.0.0.1', + success: true, + }); + }).not.toThrow(); + }); + + it('should log successful login', done => { + const adapter = new WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logUserLogin({ + eventType: 'USER_LOGIN', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + username: 'testuser', + sessionToken: 'token123', + ip: '127.0.0.1', + success: true, + authMethod: 'password', + }); + + setTimeout(() => { + const logFiles = getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logUserLogin({ + eventType: 'USER_LOGIN', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + username: 'testuser', + ip: '127.0.0.1', + success: false, + error: 'Invalid credentials', + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter(); + expect(() => { + adapter.logDataView({ + eventType: 'DATA_VIEW', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + success: true, + className: 'TestClass', + query: {}, + resultCount: 5, + }); + }).not.toThrow(); + }); + + it('should log data view event', done => { + const adapter = new WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataView({ + eventType: 'DATA_VIEW', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + sessionToken: 'token123', + ip: '127.0.0.1', + success: true, + className: 'TestClass', + query: { name: 'test' }, + resultCount: 5, + objectIds: ['obj1', 'obj2'], + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataCreate({ + eventType: 'DATA_CREATE', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + ip: '127.0.0.1', + success: true, + className: 'TestClass', + objectId: 'obj1', + data: { name: 'test', value: 123 }, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataCreate({ + eventType: 'DATA_CREATE', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + success: false, + error: 'Validation failed', + className: 'TestClass', + objectId: 'obj1', + data: {}, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataUpdate({ + eventType: 'DATA_UPDATE', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + ip: '127.0.0.1', + success: true, + className: 'TestClass', + objectId: 'obj1', + updatedFields: { name: 'updated' }, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logDataDelete({ + eventType: 'DATA_DELETE', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + ip: '127.0.0.1', + success: true, + className: 'TestClass', + objectId: 'obj1', + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + const acl = { '*': { read: true }, user1: { write: true } }; + + adapter.logACLModify({ + eventType: 'ACL_MODIFY', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + ip: '127.0.0.1', + success: true, + className: 'TestClass', + objectId: 'obj1', + acl, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logSchemaModify({ + eventType: 'SCHEMA_MODIFY', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + ip: '127.0.0.1', + success: true, + className: 'NewClass', + operation: 'create', + schemaData: { fields: { name: { type: 'String' } } }, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logSchemaModify({ + eventType: 'SCHEMA_MODIFY', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + success: true, + className: 'ExistingClass', + operation: 'update', + schemaData: { fields: { age: { type: 'Number' } } }, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(testLogFolder)[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + expect(logContent).toContain('update'); + done(); + }, 100); + }); + + it('should log schema deletion', done => { + const adapter = new WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logSchemaModify({ + eventType: 'SCHEMA_MODIFY', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + success: true, + className: 'OldClass', + operation: 'delete', + schemaData: {}, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logPushSend({ + eventType: 'PUSH_SEND', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + ip: '127.0.0.1', + success: true, + payload: { alert: 'test' }, + target: { deviceType: 'ios', channels: ['channel1', 'channel2'] }, + deviceCount: 100, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + adapter.logPushSend({ + eventType: 'PUSH_SEND', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + success: false, + error: 'No devices found', + target: {}, + deviceCount: 0, + }); + + setTimeout(() => { + const logFile = path.join(testLogFolder, getLogFiles(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 WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + }); + + expect(fs.existsSync(testLogFolder)).toBe(true); + }); + + it('should create logs with date pattern in filename', done => { + const adapter = new WinstonFileAuditLogAdapter({ + auditLogFolder: testLogFolder, + datePattern: 'YYYY-MM-DD', + }); + + adapter.logSystemEvent({ + eventType: 'SYSTEM', + timestamp: new Date().toISOString(), + appId: testAppId, + success: true, + }); + + setTimeout(() => { + const logFiles = getLogFiles(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..5031f33932 --- /dev/null +++ b/spec/AuditLogController.spec.js @@ -0,0 +1,313 @@ +'use strict'; + +const AuditLogController = require('../lib/Controllers/AuditLogController').AuditLogController; + +describe('AuditLogController', () => { + let controller; + let mockAdapter; + const testAppId = 'testApp123'; + + beforeEach(() => { + mockAdapter = { + logUserLogin: jasmine.createSpy('logUserLogin').and.returnValue(Promise.resolve()), + logDataView: jasmine.createSpy('logDataView').and.returnValue(Promise.resolve()), + logDataCreate: jasmine.createSpy('logDataCreate').and.returnValue(Promise.resolve()), + logDataUpdate: jasmine.createSpy('logDataUpdate').and.returnValue(Promise.resolve()), + logDataDelete: jasmine.createSpy('logDataDelete').and.returnValue(Promise.resolve()), + logACLModify: jasmine.createSpy('logACLModify').and.returnValue(Promise.resolve()), + logSchemaModify: jasmine.createSpy('logSchemaModify').and.returnValue(Promise.resolve()), + logPushSend: jasmine.createSpy('logPushSend').and.returnValue(Promise.resolve()), + isEnabled: jasmine.createSpy('isEnabled').and.returnValue(true), + }; + + controller = new AuditLogController(mockAdapter, testAppId, {}); + }); + + describe('constructor', () => { + it('should initialize with adapter', () => { + expect(controller.adapter).toBe(mockAdapter); + expect(controller.appId).toBe(testAppId); + }); + + it('should initialize with null adapter', () => { + const nullController = new AuditLogController(null, testAppId, {}); + expect(nullController.adapter).toBeNull(); + }); + }); + + 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); + }); + }); + + describe('logUserLogin', () => { + it('should log successful login with proper event structure', () => { + 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( + jasmine.objectContaining({ + eventType: 'USER_LOGIN', + appId: testAppId, + userId: 'user1', + username: 'testuser', + sessionToken: 'token1', + ip: '127.0.0.1', + success: true, + authMethod: '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({ + eventType: 'USER_LOGIN', + username: 'testuser', + success: false, + error: 'Invalid credentials', + }) + ); + }); + + it('should not log if adapter is disabled', () => { + mockAdapter.isEnabled.and.returnValue(false); + controller.logUserLogin({ auth: {}, req: {}, success: true }); + expect(mockAdapter.logUserLogin).not.toHaveBeenCalled(); + }); + + it('should not log if adapter is null', () => { + controller.adapter = null; + controller.logUserLogin({ auth: {}, req: {}, success: true }); + // 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( + jasmine.objectContaining({ + eventType: 'DATA_VIEW', + appId: testAppId, + userId: 'user1', + sessionToken: 'token1', + ip: '127.0.0.1', + className: 'TestClass', + query: { name: 'test' }, + resultCount: 5, + objectIds: ['obj1', 'obj2'], + success: true, + }) + ); + }); + + 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', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'TestClass', + objectId: 'newObj1', + data: { name: 'test' }, + success: true, + }; + + controller.logDataCreate(params); + + expect(mockAdapter.logDataCreate).toHaveBeenCalledWith( + jasmine.objectContaining({ + eventType: 'DATA_CREATE', + className: 'TestClass', + objectId: 'newObj1', + success: true, + }) + ); + }); + + 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', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'TestClass', + objectId: 'obj1', + updatedFields: { name: 'updated' }, + success: true, + }; + + controller.logDataUpdate(params); + + expect(mockAdapter.logDataUpdate).toHaveBeenCalledWith( + jasmine.objectContaining({ + eventType: 'DATA_UPDATE', + className: 'TestClass', + objectId: 'obj1', + success: true, + }) + ); + }); + }); + + 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( + jasmine.objectContaining({ + eventType: 'DATA_DELETE', + className: 'TestClass', + objectId: 'obj1', + success: true, + }) + ); + }); + }); + + describe('logACLModify', () => { + it('should log ACL modification', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + className: 'TestClass', + objectId: 'obj1', + newACL: { user1: { read: true, write: true } }, + success: true, + }; + + controller.logACLModify(params); + + expect(mockAdapter.logACLModify).toHaveBeenCalledWith( + jasmine.objectContaining({ + eventType: 'ACL_MODIFY', + className: 'TestClass', + objectId: 'obj1', + success: true, + }) + ); + }); + }); + + 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({ + eventType: 'SCHEMA_MODIFY', + operation: 'create', + className: 'NewClass', + success: true, + }) + ); + }); + }); + + describe('logPushSend', () => { + it('should log push notification', () => { + const params = { + auth: { user: { id: 'user1' } }, + req: { headers: {}, ip: '127.0.0.1' }, + payload: { alert: 'test' }, + query: { deviceType: 'ios' }, + channels: ['channel1'], + targetCount: 100, + success: true, + }; + + controller.logPushSend(params); + + expect(mockAdapter.logPushSend).toHaveBeenCalledWith( + jasmine.objectContaining({ + eventType: 'PUSH_SEND', + success: true, + deviceCount: 100, + }) + ); + }); + }); +}); diff --git a/spec/AuditLogFilter.spec.js b/spec/AuditLogFilter.spec.js new file mode 100644 index 0000000000..85dcbbcbfe --- /dev/null +++ b/spec/AuditLogFilter.spec.js @@ -0,0 +1,218 @@ +'use strict'; + +const { AuditLogFilter } = require('../lib/Adapters/AuditLog/AuditLogFilter'); + +describe('AuditLogFilter', () => { + const testAppId = 'testApp123'; + + const createEvent = (overrides = {}) => ({ + eventType: 'DATA_CREATE', + timestamp: new Date().toISOString(), + appId: testAppId, + userId: 'user1', + success: true, + className: 'TestClass', + ...overrides, + }); + + describe('event type filtering', () => { + it('should allow events in the events whitelist', () => { + const filter = new AuditLogFilter({ + events: ['USER_LOGIN', 'DATA_DELETE'], + }); + + expect(filter.shouldLog(createEvent({ eventType: 'USER_LOGIN' }))).toBe(true); + expect(filter.shouldLog(createEvent({ eventType: 'DATA_DELETE' }))).toBe(true); + expect(filter.shouldLog(createEvent({ eventType: 'DATA_CREATE' }))).toBe(false); + }); + + it('should allow all events when events filter is not specified', () => { + const filter = new AuditLogFilter({}); + + expect(filter.shouldLog(createEvent({ eventType: 'USER_LOGIN' }))).toBe(true); + expect(filter.shouldLog(createEvent({ eventType: 'DATA_CREATE' }))).toBe(true); + expect(filter.shouldLog(createEvent({ eventType: 'SCHEMA_MODIFY' }))).toBe(true); + }); + }); + + describe('class name filtering', () => { + it('should filter by includeClasses', () => { + const filter = new AuditLogFilter({ + includeClasses: ['_User', 'Order'], + }); + + expect(filter.shouldLog(createEvent({ className: '_User' }))).toBe(true); + expect(filter.shouldLog(createEvent({ className: 'Order' }))).toBe(true); + expect(filter.shouldLog(createEvent({ className: 'Product' }))).toBe(false); + }); + + it('should filter by excludeClasses', () => { + const filter = new AuditLogFilter({ + excludeClasses: ['_Session', 'TempData'], + }); + + expect(filter.shouldLog(createEvent({ className: '_Session' }))).toBe(false); + expect(filter.shouldLog(createEvent({ className: 'TempData' }))).toBe(false); + expect(filter.shouldLog(createEvent({ className: '_User' }))).toBe(true); + }); + + it('should handle events without className', () => { + const filter = new AuditLogFilter({ + includeClasses: ['_User'], + }); + + const loginEvent = createEvent({ eventType: 'USER_LOGIN' }); + delete loginEvent.className; + + expect(filter.shouldLog(loginEvent)).toBe(true); + }); + }); + + describe('master key filtering', () => { + it('should exclude master key operations when excludeMasterKey is true', () => { + const filter = new AuditLogFilter({ + excludeMasterKey: true, + }); + + expect(filter.shouldLog(createEvent({ isMasterKey: true }))).toBe(false); + expect(filter.shouldLog(createEvent({ isMasterKey: false }))).toBe(true); + expect(filter.shouldLog(createEvent())).toBe(true); + }); + + it('should allow master key operations when excludeMasterKey is false', () => { + const filter = new AuditLogFilter({ + excludeMasterKey: false, + }); + + expect(filter.shouldLog(createEvent({ isMasterKey: true }))).toBe(true); + }); + }); + + describe('role filtering', () => { + it('should filter by includeRoles', () => { + const filter = new AuditLogFilter({ + includeRoles: ['admin', 'moderator'], + }); + + expect(filter.shouldLog(createEvent({ roles: ['admin'] }))).toBe(true); + expect(filter.shouldLog(createEvent({ roles: ['moderator'] }))).toBe(true); + expect(filter.shouldLog(createEvent({ roles: ['user'] }))).toBe(false); + expect(filter.shouldLog(createEvent({ roles: ['admin', 'user'] }))).toBe(true); + }); + + it('should filter by excludeRoles', () => { + const filter = new AuditLogFilter({ + excludeRoles: ['bot', 'system'], + }); + + expect(filter.shouldLog(createEvent({ roles: ['bot'] }))).toBe(false); + expect(filter.shouldLog(createEvent({ roles: ['system'] }))).toBe(false); + expect(filter.shouldLog(createEvent({ roles: ['admin'] }))).toBe(true); + expect(filter.shouldLog(createEvent({ roles: ['admin', 'bot'] }))).toBe(false); + }); + }); + + describe('custom filter function', () => { + it('should apply custom filter function', () => { + const filter = new AuditLogFilter({ + filter: (event) => event.userId !== 'system', + }); + + expect(filter.shouldLog(createEvent({ userId: 'user1' }))).toBe(true); + expect(filter.shouldLog(createEvent({ userId: 'system' }))).toBe(false); + }); + + it('should handle custom filter errors gracefully', () => { + const filter = new AuditLogFilter({ + filter: () => { + throw new Error('Filter error'); + }, + }); + + // Should return true (fail-open) on error + expect(filter.shouldLog(createEvent())).toBe(true); + }); + }); + + describe('filter precedence', () => { + it('should apply filters in order: event type → class → master key → roles → custom', () => { + const filter = new AuditLogFilter({ + events: ['DATA_CREATE'], + includeClasses: ['TestClass'], + excludeMasterKey: true, + includeRoles: ['admin'], + filter: (event) => event.userId === 'user1', + }); + + // Pass all filters + expect( + filter.shouldLog( + createEvent({ + eventType: 'DATA_CREATE', + className: 'TestClass', + isMasterKey: false, + roles: ['admin'], + userId: 'user1', + }) + ) + ).toBe(true); + + // Fail event type filter + expect( + filter.shouldLog( + createEvent({ + eventType: 'DATA_DELETE', + className: 'TestClass', + roles: ['admin'], + }) + ) + ).toBe(false); + + // Fail class filter + expect( + filter.shouldLog( + createEvent({ + eventType: 'DATA_CREATE', + className: 'OtherClass', + roles: ['admin'], + }) + ) + ).toBe(false); + + // Fail master key filter + expect( + filter.shouldLog( + createEvent({ + eventType: 'DATA_CREATE', + className: 'TestClass', + isMasterKey: true, + roles: ['admin'], + }) + ) + ).toBe(false); + + // Fail roles filter + expect( + filter.shouldLog( + createEvent({ + eventType: 'DATA_CREATE', + className: 'TestClass', + roles: ['user'], + }) + ) + ).toBe(false); + + // Fail custom filter + expect( + filter.shouldLog( + createEvent({ + eventType: 'DATA_CREATE', + className: 'TestClass', + roles: ['admin'], + userId: 'user2', + }) + ) + ).toBe(false); + }); + }); +}); diff --git a/spec/AuditLogSchemas.spec.js b/spec/AuditLogSchemas.spec.js new file mode 100644 index 0000000000..904d2334ab --- /dev/null +++ b/spec/AuditLogSchemas.spec.js @@ -0,0 +1,240 @@ +'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'); + const getLogFiles = (folder) => fs.readdirSync(folder).filter(f => f.endsWith('.log')); + + 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 = getLogFiles(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 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 = getLogFiles(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const updateLogs = logContent.split('\n').filter(line => + line.includes('SCHEMA_MODIFY') && line.includes('update') + ); + + expect(updateLogs.length).toBeGreaterThan(0); + expect(updateLogs[0]).toContain('AuditSchemaUpdate'); + }); + + 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 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 = getLogFiles(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const deleteLogs = logContent.split('\n').filter(line => + line.includes('SCHEMA_MODIFY') && line.includes('delete') + ); + + expect(deleteLogs.length).toBeGreaterThan(0); + expect(deleteLogs[0]).toContain('AuditSchemaDelete'); + }); + + 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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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..b12f9d7077 --- /dev/null +++ b/spec/AuditLogging.e2e.spec.js @@ -0,0 +1,485 @@ +'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'); + const getLogFiles = (folder) => fs.readdirSync(folder).filter(f => f.endsWith('.log')); + + 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: { + adapterOptions: { + 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 = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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 = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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.appId).toBeDefined(); + expect(loginLog.userId).toBeDefined(); + expect(loginLog.success).toBeDefined(); + expect(loginLog.ip).toBeDefined(); + }); + + it('should properly format JSON in log files', async () => { + await reconfigureServer({ + auditLog: { + adapterOptions: { + auditLogFolder: testLogFolder, + }, + }, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'jsonuser', + password: 'password123', + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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: { + adapterOptions: { + 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 = getLogFiles(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..8ba0315892 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,169 @@ 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'); + const getLogFiles = (folder) => fs.readdirSync(folder).filter(f => f.endsWith('.log')); + + 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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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..758426ca3c 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -2172,3 +2172,205 @@ 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'); + const getLogFiles = (folder) => fs.readdirSync(folder).filter(f => f.endsWith('.log')); + + 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 = getLogFiles(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(); + + obj.set('name', 'updated'); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = getLogFiles(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const updateLogs = logContent.split('\n').filter(line => line.includes('DATA_UPDATE')); + + expect(updateLogs.length).toBeGreaterThan(0); + expect(updateLogs[0]).toContain('AuditCRUDUpdate'); + expect(updateLogs[0]).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 obj.destroy(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = getLogFiles(testLogFolder); + expect(logFiles.length).toBeGreaterThan(0); + + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const deleteLogs = logContent.split('\n').filter(line => line.includes('DATA_DELETE')); + + expect(deleteLogs.length).toBeGreaterThan(0); + expect(deleteLogs[0]).toContain('AuditCRUDDelete'); + expect(deleteLogs[0]).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(); + + const newAcl = new Parse.ACL(user); + newAcl.setPublicReadAccess(true); + obj.setACL(newAcl); + await obj.save(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const logFiles = getLogFiles(testLogFolder); + const logFile = path.join(testLogFolder, logFiles[0]); + const logContent = fs.readFileSync(logFile, 'utf8'); + const aclLogs = logContent.split('\n').filter(line => line.includes('ACL_MODIFY')); + + expect(aclLogs.length).toBeGreaterThan(0); + expect(aclLogs[0]).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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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..d91c693b60 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -5374,4 +5374,197 @@ 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'); + const getLogFiles = (folder) => fs.readdirSync(folder).filter(f => f.endsWith('.log')); + + 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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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 = getLogFiles(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/AuditLog/AuditLogAdapterInterface.ts b/src/Adapters/AuditLog/AuditLogAdapterInterface.ts new file mode 100644 index 0000000000..8546b935f6 --- /dev/null +++ b/src/Adapters/AuditLog/AuditLogAdapterInterface.ts @@ -0,0 +1,188 @@ +/** + * Audit event types supported by the system + */ +export type AuditEventType = + | 'SYSTEM' + | 'USER_LOGIN' + | 'DATA_VIEW' + | 'DATA_CREATE' + | 'DATA_UPDATE' + | 'DATA_DELETE' + | 'ACL_MODIFY' + | 'SCHEMA_MODIFY' + | 'PUSH_SEND'; + +/** + * Base audit event structure containing common fields + */ +export interface AuditEvent { + eventType: AuditEventType; + timestamp: string; + appId: string; + userId?: string; + sessionToken?: string; + ip?: string; + success: boolean; + error?: string; + isMasterKey?: boolean; + roles?: string[]; +} + +/** + * User login event + */ +export interface UserLoginEvent extends AuditEvent { + eventType: 'USER_LOGIN'; + username?: string; + authMethod?: string; +} + +/** + * Data view/query event + */ +export interface DataViewEvent extends AuditEvent { + eventType: 'DATA_VIEW'; + className: string; + query?: Record; + resultCount?: number; + objectIds?: string[]; +} + +/** + * Data creation event + */ +export interface DataCreateEvent extends AuditEvent { + eventType: 'DATA_CREATE'; + className: string; + objectId?: string; + data?: Record; +} + +/** + * Data update event + */ +export interface DataUpdateEvent extends AuditEvent { + eventType: 'DATA_UPDATE'; + className: string; + objectId: string; + updatedFields?: Record; +} + +/** + * Data deletion event + */ +export interface DataDeleteEvent extends AuditEvent { + eventType: 'DATA_DELETE'; + className: string; + objectId: string; +} + +/** + * ACL modification event + */ +export interface ACLModifyEvent extends AuditEvent { + eventType: 'ACL_MODIFY'; + className: string; + objectId: string; + acl?: Record; +} + +/** + * Schema modification event + */ +export interface SchemaModifyEvent extends AuditEvent { + eventType: 'SCHEMA_MODIFY'; + operation: 'create' | 'update' | 'delete'; + className: string; + schemaData?: Record; +} + +/** + * Push notification send event + */ +export interface PushSendEvent extends AuditEvent { + eventType: 'PUSH_SEND'; + payload?: Record; + target?: Record; + deviceCount?: number; +} + +/** + * AuditLogAdapterInterface is the interface for audit log storage adapters. + * All audit log adapters must implement this interface to provide GDPR-compliant + * audit logging capabilities. + */ +export abstract class AuditLogAdapterInterface { + /** + * Initialize the adapter. Called when the adapter is first created. + * Optional lifecycle method for setup operations (e.g., database connections). + */ + initialize?(): Promise; + + /** + * Close/cleanup the adapter. Called when Parse Server is shutting down. + * Optional lifecycle method for cleanup operations (e.g., closing connections). + */ + close?(): Promise; + + /** + * Check if audit logging is enabled for this adapter. + */ + abstract isEnabled(): boolean; + + /** + * Log a user login event. + * Captures authentication attempts (both successful and failed). + */ + abstract logUserLogin(event: UserLoginEvent): Promise; + + /** + * Log a data view/query event. + * Captures when users query or read data from Parse classes. + */ + abstract logDataView(event: DataViewEvent): Promise; + + /** + * Log a data creation event. + * Captures when new objects are created in Parse classes. + */ + abstract logDataCreate(event: DataCreateEvent): Promise; + + /** + * Log a data update event. + * Captures when objects are modified in Parse classes. + */ + abstract logDataUpdate(event: DataUpdateEvent): Promise; + + /** + * Log a data deletion event. + * Captures when objects are deleted from Parse classes. + */ + abstract logDataDelete(event: DataDeleteEvent): Promise; + + /** + * Log an ACL modification event. + * Captures when object access control lists are changed. + */ + abstract logACLModify(event: ACLModifyEvent): Promise; + + /** + * Log a schema modification event. + * Captures when Parse class schemas are created, updated, or deleted. + */ + abstract logSchemaModify(event: SchemaModifyEvent): Promise; + + /** + * Log a push notification send event. + * Captures when push notifications are sent to devices. + */ + abstract logPushSend(event: PushSendEvent): Promise; + + /** + * Log a generic system event. + * Optional method for logging system-level events not covered by other methods. + */ + logSystemEvent?(event: AuditEvent): Promise; +} + +export default AuditLogAdapterInterface; diff --git a/src/Adapters/AuditLog/AuditLogFilter.ts b/src/Adapters/AuditLog/AuditLogFilter.ts new file mode 100644 index 0000000000..bf5a7b4459 --- /dev/null +++ b/src/Adapters/AuditLog/AuditLogFilter.ts @@ -0,0 +1,216 @@ +import type { AuditEvent } from './AuditLogAdapterInterface'; + +/** + * Filter configuration for selective audit logging + */ +export interface AuditLogFilterConfig { + /** Whitelist of event types to log (if undefined, all events are logged) */ + events?: string[]; + /** Whitelist of Parse classes to log (if defined, only these classes are logged) */ + includeClasses?: string[]; + /** Blacklist of Parse classes to exclude from logging */ + excludeClasses?: string[]; + /** Whether to exclude operations performed with master key */ + excludeMasterKey?: boolean; + /** Whitelist of user roles to log (if defined, only these roles are logged) */ + includeRoles?: string[]; + /** Blacklist of user roles to exclude from logging */ + excludeRoles?: string[]; + /** Custom filter function for advanced filtering */ + filter?: (event: AuditEvent) => boolean; +} + +/** + * AuditLogFilter provides filtering logic to determine which audit events should be logged. + * This allows for selective logging based on event types, classes, users, and custom logic. + */ +export class AuditLogFilter { + private config: AuditLogFilterConfig; + + constructor(config: AuditLogFilterConfig = {}) { + this.config = config; + } + + /** + * Determine if an event should be logged based on filter configuration. + * Evaluation order: + * 1. Event type filtering (whitelist) + * 2. Class name filtering (whitelist/blacklist) + * 3. Master key filtering (blacklist) + * 4. User role filtering (whitelist/blacklist) + * 5. Custom filter function + */ + shouldLog(event: AuditEvent): boolean { + // If no filter config, log everything + if (!this.config || Object.keys(this.config).length === 0) { + return true; + } + + // 1. Event type filtering (whitelist) + if (!this.checkEventType(event)) { + return false; + } + + // 2. Class name filtering (whitelist/blacklist) + if (!this.checkClassName(event)) { + return false; + } + + // 3. Master key filtering (exclude if configured) + if (!this.checkMasterKey(event)) { + return false; + } + + // 4. User role filtering (whitelist/blacklist) + if (!this.checkUserRoles(event)) { + return false; + } + + // 5. Custom filter function + if (!this.checkCustomFilter(event)) { + return false; + } + + return true; + } + + /** + * Check if event type is allowed + */ + private checkEventType(event: AuditEvent): boolean { + const { events } = this.config; + + // If no event filter, allow all event types + if (!events || !Array.isArray(events) || events.length === 0) { + return true; + } + + // Whitelist: only log events in the list + return events.includes(event.eventType); + } + + /** + * Check if class name is allowed (for class-based events) + */ + private checkClassName(event: AuditEvent): boolean { + const { includeClasses, excludeClasses } = this.config; + + // Extract className from event (different event types store it differently) + const className = this.extractClassName(event); + + // If event doesn't have a className, skip class filtering + if (!className) { + return true; + } + + // Blacklist check: exclude specific classes + if (excludeClasses && Array.isArray(excludeClasses) && excludeClasses.length > 0) { + if (excludeClasses.includes(className)) { + return false; + } + } + + // Whitelist check: only include specific classes + if (includeClasses && Array.isArray(includeClasses) && includeClasses.length > 0) { + return includeClasses.includes(className); + } + + // If no class filters, allow + return true; + } + + /** + * Extract className from event based on event structure + */ + private extractClassName(event: AuditEvent): string | undefined { + // Type-safe extraction based on event structure + if ('className' in event) { + return (event as any).className; + } + return undefined; + } + + /** + * Check if master key operations should be excluded + */ + private checkMasterKey(event: AuditEvent): boolean { + const { excludeMasterKey } = this.config; + + // If not configured to exclude master key, allow + if (!excludeMasterKey) { + return true; + } + + // Exclude if event was performed with master key + if (event.isMasterKey === true) { + return false; + } + + return true; + } + + /** + * Check if user roles are allowed + */ + private checkUserRoles(event: AuditEvent): boolean { + const { includeRoles, excludeRoles } = this.config; + + // If event doesn't have roles, skip role filtering + if (!event.roles || !Array.isArray(event.roles) || event.roles.length === 0) { + // If includeRoles is specified, absence of roles means exclude + if (includeRoles && Array.isArray(includeRoles) && includeRoles.length > 0) { + return false; + } + return true; + } + + // Blacklist check: exclude if user has any excluded role + if (excludeRoles && Array.isArray(excludeRoles) && excludeRoles.length > 0) { + const hasExcludedRole = event.roles.some(role => excludeRoles.includes(role)); + if (hasExcludedRole) { + return false; + } + } + + // Whitelist check: only include if user has at least one included role + if (includeRoles && Array.isArray(includeRoles) && includeRoles.length > 0) { + const hasIncludedRole = event.roles.some(role => includeRoles.includes(role)); + return hasIncludedRole; + } + + // If no role filters, allow + return true; + } + + /** + * Check custom filter function + */ + private checkCustomFilter(event: AuditEvent): boolean { + const { filter } = this.config; + + // If no custom filter, allow + if (!filter || typeof filter !== 'function') { + return true; + } + + try { + // Execute custom filter function + return filter(event) === true; + } catch (error) { + // If custom filter throws, log error and allow the event + // (fail open to prevent filter bugs from blocking all logging) + console.error('Error in custom audit log filter:', error); + return true; + } + } +} + +/** + * Static helper to quickly check if an event should be logged + */ +export function shouldLogEvent(event: AuditEvent, config: AuditLogFilterConfig): boolean { + const filter = new AuditLogFilter(config); + return filter.shouldLog(event); +} + +export default AuditLogFilter; diff --git a/src/Adapters/AuditLog/WinstonFileAuditLogAdapter.ts b/src/Adapters/AuditLog/WinstonFileAuditLogAdapter.ts new file mode 100644 index 0000000000..70f8c8107f --- /dev/null +++ b/src/Adapters/AuditLog/WinstonFileAuditLogAdapter.ts @@ -0,0 +1,242 @@ +import winston from 'winston'; +import { format } from 'winston'; +import fs from 'fs'; +import path from 'path'; +import DailyRotateFile from 'winston-daily-rotate-file'; +import { + AuditLogAdapterInterface, + type UserLoginEvent, + type DataViewEvent, + type DataCreateEvent, + type DataUpdateEvent, + type DataDeleteEvent, + type ACLModifyEvent, + type SchemaModifyEvent, + type PushSendEvent, + type AuditEvent, +} from './AuditLogAdapterInterface'; + +/** + * Options for Winston file-based audit log adapter + */ +export interface WinstonFileAuditLogAdapterOptions { + /** Directory where audit log files will be stored */ + auditLogFolder: string; + /** Date pattern for log rotation (default: 'YYYY-MM-DD') */ + datePattern?: string; + /** Maximum size of each log file before rotation (default: '20m') */ + maxSize?: string; + /** Maximum number of days to retain logs (default: '14d') */ + maxFiles?: string; +} + +/** + * Winston file-based implementation of the AuditLogAdapter interface. + * Stores audit logs in daily-rotated JSON files with configurable retention. + */ +export class WinstonFileAuditLogAdapter extends AuditLogAdapterInterface { + private logger: winston.Logger; + private options: WinstonFileAuditLogAdapterOptions; + private enabled: boolean; + + constructor(options: WinstonFileAuditLogAdapterOptions) { + super(); + this.options = options; + this.enabled = false; + this.logger = winston.createLogger(); + + if (options && options.auditLogFolder) { + this.configure(); + } + } + + /** + * Configure the Winston logger with daily rotation + */ + private configure(): void { + const { auditLogFolder, datePattern, maxSize, maxFiles } = this.options; + + if (!auditLogFolder) { + return; + } + + // Resolve to absolute path + let logFolder = auditLogFolder; + if (!path.isAbsolute(logFolder)) { + logFolder = path.resolve(process.cwd(), logFolder); + } + + // Create directory if it doesn't exist + try { + fs.mkdirSync(logFolder, { recursive: true }); + } catch (error) { + console.error('Failed to create audit log folder:', error); + return; + } + + // Configure Winston with daily rotation transport + try { + const transport = new DailyRotateFile({ + dirname: logFolder, + filename: 'parse-server-audit-%DATE%.log', + datePattern: datePattern || 'YYYY-MM-DD', + maxSize: maxSize || '20m', + maxFiles: maxFiles || '14d', + json: true, + format: format.combine(format.timestamp(), format.json()), + }); + + transport.name = 'parse-server-audit'; + + this.logger.configure({ + transports: [transport], + level: 'info', + }); + + this.enabled = true; + } catch (error) { + console.error('Failed to configure audit logger:', error); + this.enabled = false; + } + } + + /** + * Initialize the adapter (optional lifecycle method) + */ + async initialize(): Promise { + // Winston is configured synchronously in constructor + // This method is available for future async initialization needs + return Promise.resolve(); + } + + /** + * Close the adapter and flush any pending logs + */ + async close(): Promise { + return new Promise((resolve) => { + if (!this.logger || !this.enabled) { + resolve(); + return; + } + + // Close all transports + this.logger.close(); + this.enabled = false; + resolve(); + }); + } + + /** + * Check if audit logging is enabled + */ + isEnabled(): boolean { + return this.enabled && this.logger.transports && this.logger.transports.length > 0; + } + + /** + * Write an audit event to the log + */ + private async writeEvent(event: AuditEvent): Promise { + if (!this.isEnabled()) { + return Promise.resolve(); + } + + try { + // Prepare audit entry with common fields + const auditEntry: Record = { + timestamp: new Date().toISOString(), + eventType: event.eventType, + appId: event.appId, + userId: event.userId || 'anonymous', + sessionToken: event.sessionToken ? '***masked***' : undefined, + ip: event.ip, + success: event.success !== false, + error: event.error, + isMasterKey: event.isMasterKey, + roles: event.roles, + ...event, // Spread event to include type-specific fields + }; + + // Remove undefined fields for cleaner output + Object.keys(auditEntry).forEach(key => { + if (auditEntry[key] === undefined) { + delete auditEntry[key]; + } + }); + + // Write to Winston logger + this.logger.info('audit_event', auditEntry); + + return Promise.resolve(); + } catch (error) { + // Log error but don't throw (fire-and-forget) + console.error('Error writing audit event:', error); + return Promise.resolve(); + } + } + + /** + * Log a user login event + */ + async logUserLogin(event: UserLoginEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log a data view/query event + */ + async logDataView(event: DataViewEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log a data creation event + */ + async logDataCreate(event: DataCreateEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log a data update event + */ + async logDataUpdate(event: DataUpdateEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log a data deletion event + */ + async logDataDelete(event: DataDeleteEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log an ACL modification event + */ + async logACLModify(event: ACLModifyEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log a schema modification event + */ + async logSchemaModify(event: SchemaModifyEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log a push notification send event + */ + async logPushSend(event: PushSendEvent): Promise { + return this.writeEvent(event); + } + + /** + * Log a generic system event + */ + async logSystemEvent(event: AuditEvent): Promise { + return this.writeEvent(event); + } +} + +export default WinstonFileAuditLogAdapter; diff --git a/src/Adapters/AuditLog/index.ts b/src/Adapters/AuditLog/index.ts new file mode 100644 index 0000000000..b1a264c950 --- /dev/null +++ b/src/Adapters/AuditLog/index.ts @@ -0,0 +1,19 @@ +export { AuditLogAdapterInterface } from './AuditLogAdapterInterface'; +export { WinstonFileAuditLogAdapter } from './WinstonFileAuditLogAdapter'; +export { AuditLogFilter, shouldLogEvent } from './AuditLogFilter'; + +export type { + AuditEvent, + AuditEventType, + UserLoginEvent, + DataViewEvent, + DataCreateEvent, + DataUpdateEvent, + DataDeleteEvent, + ACLModifyEvent, + SchemaModifyEvent, + PushSendEvent, +} from './AuditLogAdapterInterface'; + +export type { AuditLogFilterConfig } from './AuditLogFilter'; +export type { WinstonFileAuditLogAdapterOptions } from './WinstonFileAuditLogAdapter'; 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..cdc85e7c68 --- /dev/null +++ b/src/Adapters/Logger/AuditLogger.js @@ -0,0 +1,144 @@ +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) { + 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 + return; + } + + let logFolder = auditLogFolder; + if (!path.isAbsolute(logFolder)) { + logFolder = path.resolve(process.cwd(), logFolder); + } + + try { + fs.mkdirSync(logFolder, { recursive: true }); + } catch (e) { + console.error('Failed to create audit log folder:', e); + 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.ts b/src/Controllers/AuditLogController.ts new file mode 100644 index 0000000000..f34f62035e --- /dev/null +++ b/src/Controllers/AuditLogController.ts @@ -0,0 +1,425 @@ +import { AdaptableController } from './AdaptableController'; +import { + AuditLogAdapterInterface, + AuditLogFilter, + type AuditLogFilterConfig, + type AuditEvent, + type UserLoginEvent, + type DataViewEvent, + type DataCreateEvent, + type DataUpdateEvent, + type DataDeleteEvent, + type ACLModifyEvent, + type SchemaModifyEvent, + type PushSendEvent, +} from '../Adapters/AuditLog'; + +interface AuditLogControllerOptions { + logFilter?: AuditLogFilterConfig; +} + +interface UserContext { + userId?: string; + sessionToken?: string; + isMasterKey?: boolean; + roles?: string[]; +} + +/** + * AuditLogController + * Controller for managing GDPR-compliant audit logging + * Supports pluggable adapters and configurable filtering + */ +export class AuditLogController extends AdaptableController { + adapter: AuditLogAdapterInterface; + filter: AuditLogFilter; + appId: string; + + constructor(adapter: AuditLogAdapterInterface, appId: string, options: AuditLogControllerOptions = {}) { + super(adapter, appId, options); + this.filter = new AuditLogFilter(options.logFilter || {}); + } + + expectedAdapterType() { + return AuditLogAdapterInterface; + } + + /** + * Get IP address from request + */ + private getIPAddress(req: any): string | undefined { + 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 + */ + private getUserContext(auth: any): UserContext { + if (!auth) { + return { + userId: undefined, + sessionToken: undefined, + isMasterKey: false, + roles: [], + }; + } + return { + userId: auth.user?.id || auth.user?.objectId, + sessionToken: auth.sessionToken, + isMasterKey: auth.isMaster || auth.isMasterKey || false, + roles: auth.user?.get('roles') || [], + }; + } + + /** + * Mask sensitive data (passwords, tokens, etc.) + */ + private maskSensitiveData(data: any): any { + 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; + } + + /** + * Write an audit event (with filtering and fire-and-forget async) + */ + private writeEvent(event: AuditEvent): void { + if (!this.adapter || !this.adapter.isEnabled()) { + return; + } + + // Apply filter + if (!this.filter.shouldLog(event)) { + return; + } + + // Determine which adapter method to call based on event type + const methodMap: Record = { + USER_LOGIN: 'logUserLogin', + DATA_VIEW: 'logDataView', + DATA_CREATE: 'logDataCreate', + DATA_UPDATE: 'logDataUpdate', + DATA_DELETE: 'logDataDelete', + ACL_MODIFY: 'logACLModify', + SCHEMA_MODIFY: 'logSchemaModify', + PUSH_SEND: 'logPushSend', + SYSTEM: 'logSystemEvent', + }; + + const methodName = methodMap[event.eventType]; + if (!methodName || typeof this.adapter[methodName] !== 'function') { + console.error(`Unknown audit event type: ${event.eventType}`); + return; + } + + // Fire-and-forget: call adapter method asynchronously without awaiting + (this.adapter[methodName] as any)(event).catch((error: Error) => { + // Log error but don't throw (fire-and-forget) + console.error(`Error writing audit event (${event.eventType}):`, error); + }); + } + + /** + * Log user login event + */ + logUserLogin(params: { + auth: any; + req: any; + username?: string; + success: boolean; + error?: string; + loginMethod?: string; + }): void { + const { auth, req, username, success, error, loginMethod } = params; + const userContext = this.getUserContext(auth); + + const event: UserLoginEvent = { + eventType: 'USER_LOGIN', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success, + error, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + username, + authMethod: loginMethod, + }; + + this.writeEvent(event); + } + + /** + * Log data view (read) event + */ + logDataView(params: { + auth: any; + req: any; + className: string; + query?: any; + resultCount?: number; + objectIds?: string[]; + }): void { + const { auth, req, className, query, resultCount, objectIds } = params; + const userContext = this.getUserContext(auth); + + const event: DataViewEvent = { + eventType: 'DATA_VIEW', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success: true, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + className, + query, + resultCount, + objectIds, + }; + + this.writeEvent(event); + } + + /** + * Log data creation event + */ + logDataCreate(params: { + auth: any; + req: any; + className: string; + objectId?: string; + data?: any; + success: boolean; + error?: string; + }): void { + const { auth, req, className, objectId, data, success, error } = params; + const userContext = this.getUserContext(auth); + + const event: DataCreateEvent = { + eventType: 'DATA_CREATE', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success, + error, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + className, + objectId, + data: this.maskSensitiveData(data), + }; + + this.writeEvent(event); + } + + /** + * Log data update event + */ + logDataUpdate(params: { + auth: any; + req: any; + className: string; + objectId: string; + updatedFields?: any; + success: boolean; + error?: string; + }): void { + const { auth, req, className, objectId, updatedFields, success, error } = params; + const userContext = this.getUserContext(auth); + + const event: DataUpdateEvent = { + eventType: 'DATA_UPDATE', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success, + error, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + className, + objectId, + updatedFields: this.maskSensitiveData(updatedFields), + }; + + this.writeEvent(event); + } + + /** + * Log data deletion event + */ + logDataDelete(params: { + auth: any; + req: any; + className: string; + objectId: string; + success: boolean; + error?: string; + }): void { + const { auth, req, className, objectId, success, error } = params; + const userContext = this.getUserContext(auth); + + const event: DataDeleteEvent = { + eventType: 'DATA_DELETE', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success, + error, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + className, + objectId, + }; + + this.writeEvent(event); + } + + /** + * Log ACL modification event + */ + logACLModify(params: { + auth: any; + req: any; + className: string; + objectId: string; + oldACL?: any; + newACL?: any; + success: boolean; + error?: string; + }): void { + const { auth, req, className, objectId, newACL, success, error } = params; + const userContext = this.getUserContext(auth); + + const event: ACLModifyEvent = { + eventType: 'ACL_MODIFY', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success, + error, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + className, + objectId, + acl: newACL, + }; + + this.writeEvent(event); + } + + /** + * Log schema modification event + */ + logSchemaModify(params: { + auth: any; + req: any; + className: string; + operation: 'create' | 'update' | 'delete'; + changes?: any; + success: boolean; + error?: string; + }): void { + const { auth, req, className, operation, changes, success, error } = params; + const userContext = this.getUserContext(auth); + + const event: SchemaModifyEvent = { + eventType: 'SCHEMA_MODIFY', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success, + error, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + operation, + className, + schemaData: changes, + }; + + this.writeEvent(event); + } + + /** + * Log push notification event + */ + logPushSend(params: { + auth: any; + req: any; + payload?: any; + query?: any; + channels?: string[]; + targetCount?: number; + success: boolean; + error?: string; + }): void { + const { auth, req, payload, query, channels, targetCount, success, error } = params; + const userContext = this.getUserContext(auth); + + const event: PushSendEvent = { + eventType: 'PUSH_SEND', + timestamp: new Date().toISOString(), + appId: this.appId, + userId: userContext.userId, + sessionToken: userContext.sessionToken, + ip: this.getIPAddress(req), + success, + error, + isMasterKey: userContext.isMasterKey, + roles: userContext.roles, + payload: this.maskSensitiveData(payload), + target: query || { channels }, + deviceCount: targetCount, + }; + + this.writeEvent(event); + } + + /** + * Check if audit logging is enabled + */ + isEnabled(): boolean { + return !!(this.adapter && this.adapter.isEnabled()); + } +} + +export default AuditLogController; diff --git a/src/Controllers/index.js b/src/Controllers/index.js index abf0950640..12bb2fda15 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 { WinstonFileAuditLogAdapter } from '../Adapters/AuditLog/WinstonFileAuditLogAdapter'; 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,31 @@ export function getLoggerController(options: ParseServerOptions): LoggerControll return new LoggerController(loggerControllerAdapter, appId, loggerOptions); } +export function getAuditLogController(options: ParseServerOptions): AuditLogController { + const { appId, auditLog } = options; + + // If no audit log config, return a controller with no adapter (disabled) + if (!auditLog) { + return new AuditLogController(null, appId, {}); + } + + // Extract configuration + const { adapter, logFilter, adapterOptions } = auditLog; + + // Load the audit log adapter using loadAdapter pattern + // Defaults to WinstonFileAuditLogAdapter if no adapter specified + const auditLogAdapter = loadAdapter( + adapter, + WinstonFileAuditLogAdapter, + adapterOptions || {} + ); + + // Create controller with adapter and filter configuration + return new AuditLogController(auditLogAdapter, appId, { + logFilter: logFilter || {}, + }); +} + export function getFilesController(options: ParseServerOptions): FilesController { const { appId, diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index a23a0de3e5..1823bd3b03 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -100,6 +100,13 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_APP_NAME', help: 'Sets the app name', }, + 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', + }, auth: { env: 'PARSE_SERVER_AUTH_PROVIDERS', help: @@ -978,6 +985,90 @@ module.exports.AccountLockoutOptions = { default: false, }, }; +module.exports.AuditLogFilterOptions = { + events: { + env: 'PARSE_SERVER_AUDIT_LOG_FILTER_EVENTS', + help: + "Whitelist of event types to log. If not set, all event types are logged.

Valid values: 'SYSTEM', 'USER_LOGIN', 'DATA_VIEW', 'DATA_CREATE', 'DATA_UPDATE', 'DATA_DELETE', 'ACL_MODIFY', 'SCHEMA_MODIFY', 'PUSH_SEND'", + action: parsers.arrayParser, + }, + excludeClasses: { + env: 'PARSE_SERVER_AUDIT_LOG_FILTER_EXCLUDE_CLASSES', + help: + "Blacklist of Parse classes to exclude from logging.

Example: ['_Session', 'TempData']", + action: parsers.arrayParser, + }, + excludeMasterKey: { + env: 'PARSE_SERVER_AUDIT_LOG_FILTER_EXCLUDE_MASTER_KEY', + help: 'Exclude operations performed with master key from logging (default: false).', + action: parsers.booleanParser, + }, + excludeRoles: { + env: 'PARSE_SERVER_AUDIT_LOG_FILTER_EXCLUDE_ROLES', + help: "Blacklist of user roles to exclude from logging.

Example: ['system', 'bot']", + action: parsers.arrayParser, + }, + filter: { + env: 'PARSE_SERVER_AUDIT_LOG_FILTER_FILTER', + help: + "Custom filter function for advanced filtering.

Function receives an audit event object and returns true to log or false to skip.

Example: (event) => event.userId !== 'system'", + required: true, + action: parsers.objectParser, + }, + includeClasses: { + env: 'PARSE_SERVER_AUDIT_LOG_FILTER_INCLUDE_CLASSES', + help: + "Whitelist of Parse classes to log. If not set, all classes are logged.

Example: ['_User', 'Order', 'Product']", + action: parsers.arrayParser, + }, + includeRoles: { + env: 'PARSE_SERVER_AUDIT_LOG_FILTER_INCLUDE_ROLES', + help: + "Whitelist of user roles to log. If not set, all roles are logged.

Example: ['admin', 'moderator']", + action: parsers.arrayParser, + }, +}; +module.exports.WinstonFileAuditLogAdapterOptions = { + auditLogFolder: { + env: 'PARSE_SERVER_AUDIT_LOG_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: 'YYYY-MM-DD').", + }, + maxFiles: { + env: 'PARSE_SERVER_AUDIT_LOG_MAX_FILES', + help: "Maximum number of log files to retain (default: '14d').", + }, + maxSize: { + env: 'PARSE_SERVER_AUDIT_LOG_MAX_SIZE', + help: "Maximum size of each log file (default: '20m').", + }, +}; +module.exports.AuditLogOptions = { + adapter: { + env: 'PARSE_SERVER_AUDIT_LOG_ADAPTER', + help: + "Audit log adapter to use. Can be:

- 'winston-file' (default): File-based logging with Winston
- String path to custom adapter module
- Object with module/class/adapter properties
- Direct adapter instance implementing AuditLogAdapterInterface", + required: true, + action: parsers.objectParser, + }, + adapterOptions: { + env: 'PARSE_SERVER_AUDIT_LOG_ADAPTER_OPTIONS', + help: + "Adapter-specific configuration options.

For 'winston-file' adapter, use WinstonFileAuditLogAdapterOptions.
For custom adapters, use adapter-specific option structure.", + required: true, + action: parsers.objectParser, + }, + logFilter: { + env: 'PARSE_SERVER_AUDIT_LOG_LOG_FILTER', + help: + 'Filter configuration for selective audit logging.

Allows filtering by event types, Parse classes, user roles, and custom logic.', + action: parsers.objectParser, + type: 'AuditLogFilterOptions', + }, +}; module.exports.PasswordPolicyOptions = { doNotAllowUsername: { env: 'PARSE_SERVER_PASSWORD_POLICY_DO_NOT_ALLOW_USERNAME', diff --git a/src/Options/docs.js b/src/Options/docs.js index bfba129bb2..617aa9feff 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -20,6 +20,7 @@ * @property {Adapter} analyticsAdapter Adapter module for the analytics * @property {String} appId Your Parse Application ID * @property {String} appName Sets the app name + * @property {AuditLogOptions} auditLog Configuration for GDPR-compliant audit logging. Logs user login, data access, data manipulation, schema changes, ACL changes, and push notifications. * @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication * @property {Adapter} cacheAdapter Adapter module for the cache * @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000 @@ -214,6 +215,32 @@ * @property {Boolean} unlockOnPasswordReset Set to `true` if the account should be unlocked after a successful password reset.

Default is `false`.
Requires options `duration` and `threshold` to be set. */ +/** + * @interface AuditLogFilterOptions + * @property {String[]} events Whitelist of event types to log. If not set, all event types are logged.

Valid values: 'SYSTEM', 'USER_LOGIN', 'DATA_VIEW', 'DATA_CREATE', 'DATA_UPDATE', 'DATA_DELETE', 'ACL_MODIFY', 'SCHEMA_MODIFY', 'PUSH_SEND' + * @property {String[]} excludeClasses Blacklist of Parse classes to exclude from logging.

Example: ['_Session', 'TempData'] + * @property {Boolean} excludeMasterKey Exclude operations performed with master key from logging (default: false). + * @property {String[]} excludeRoles Blacklist of user roles to exclude from logging.

Example: ['system', 'bot'] + * @property {Any} filter Custom filter function for advanced filtering.

Function receives an audit event object and returns true to log or false to skip.

Example: (event) => event.userId !== 'system' + * @property {String[]} includeClasses Whitelist of Parse classes to log. If not set, all classes are logged.

Example: ['_User', 'Order', 'Product'] + * @property {String[]} includeRoles Whitelist of user roles to log. If not set, all roles are logged.

Example: ['admin', 'moderator'] + */ + +/** + * @interface WinstonFileAuditLogAdapterOptions + * @property {String} auditLogFolder Folder path where audit logs will be stored. If not set, audit logging is disabled. + * @property {String} datePattern Date pattern for log file rotation (default: 'YYYY-MM-DD'). + * @property {String} maxFiles Maximum number of log files to retain (default: '14d'). + * @property {String} maxSize Maximum size of each log file (default: '20m'). + */ + +/** + * @interface AuditLogOptions + * @property {Any} adapter Audit log adapter to use. Can be:

- 'winston-file' (default): File-based logging with Winston
- String path to custom adapter module
- Object with module/class/adapter properties
- Direct adapter instance implementing AuditLogAdapterInterface + * @property {Any} adapterOptions Adapter-specific configuration options.

For 'winston-file' adapter, use WinstonFileAuditLogAdapterOptions.
For custom adapters, use adapter-specific option structure. + * @property {AuditLogFilterOptions} logFilter Filter configuration for selective audit logging.

Allows filtering by event types, Parse classes, user roles, and custom logic. + */ + /** * @interface PasswordPolicyOptions * @property {Boolean} doNotAllowUsername Set to `true` to disallow the username as part of the password.

Default is `false`. diff --git a/src/Options/index.js b/src/Options/index.js index b1827d808a..304b2f0527 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -211,6 +211,9 @@ 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 */ + auditLog: ?AuditLogOptions; /* The password policy for enforcing password related rules. */ passwordPolicy: ?PasswordPolicyOptions; /* Adapter module for the cache */ @@ -538,6 +541,71 @@ export interface AccountLockoutOptions { unlockOnPasswordReset: ?boolean; } +export interface AuditLogFilterOptions { + /* Whitelist of event types to log. If not set, all event types are logged. +

+ Valid values: 'SYSTEM', 'USER_LOGIN', 'DATA_VIEW', 'DATA_CREATE', 'DATA_UPDATE', 'DATA_DELETE', 'ACL_MODIFY', 'SCHEMA_MODIFY', 'PUSH_SEND' */ + events: ?(string[]); + /* Whitelist of Parse classes to log. If not set, all classes are logged. +

+ Example: ['_User', 'Order', 'Product'] */ + includeClasses: ?(string[]); + /* Blacklist of Parse classes to exclude from logging. +

+ Example: ['_Session', 'TempData'] */ + excludeClasses: ?(string[]); + /* Exclude operations performed with master key from logging (default: false). */ + excludeMasterKey: ?boolean; + /* Whitelist of user roles to log. If not set, all roles are logged. +

+ Example: ['admin', 'moderator'] */ + includeRoles: ?(string[]); + /* Blacklist of user roles to exclude from logging. +

+ Example: ['system', 'bot'] */ + excludeRoles: ?(string[]); + /* Custom filter function for advanced filtering. +

+ Function receives an audit event object and returns true to log or false to skip. +

+ Example: (event) => event.userId !== 'system' */ + filter: any; +} + +export interface WinstonFileAuditLogAdapterOptions { + /* Folder path where audit logs will be stored. If not set, audit logging is disabled. */ + auditLogFolder: ?string; + /* Date pattern for log file rotation (default: 'YYYY-MM-DD'). */ + datePattern: ?string; + /* Maximum size of each log file (default: '20m'). */ + maxSize: ?string; + /* Maximum number of log files to retain (default: '14d'). */ + maxFiles: ?string; +} + +export interface AuditLogOptions { + /* Audit log adapter to use. Can be: +

+ - 'winston-file' (default): File-based logging with Winston +
+ - String path to custom adapter module +
+ - Object with module/class/adapter properties +
+ - Direct adapter instance implementing AuditLogAdapterInterface */ + adapter: any; + /* Filter configuration for selective audit logging. +

+ Allows filtering by event types, Parse classes, user roles, and custom logic. */ + logFilter: ?AuditLogFilterOptions; + /* Adapter-specific configuration options. +

+ For 'winston-file' adapter, use WinstonFileAuditLogAdapterOptions. +
+ For custom adapters, use adapter-specific option structure. */ + adapterOptions: any; +} + 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) { diff --git a/src/rest.js b/src/rest.js index 1f9dbacb73..3eef39f3a7 100644 --- a/src/rest.js +++ b/src/rest.js @@ -140,6 +140,20 @@ function del(config, auth, className, objectId, context) { // Notify LiveQuery server if possible const perms = schemaController.getClassLevelPermissions(className); config.liveQueryController.onAfterDelete(className, inflatedObject, null, perms); + + // Audit log successful delete + try { + config.auditLogController?.logDataDelete({ + auth, + req: { config }, + className, + objectId, + success: true, + }); + } catch (error) { + config.loggerController.error('Audit logging error in rest.del', { error }); + } + return triggers.maybeRunTrigger( triggers.Types.afterDelete, auth, @@ -150,6 +164,19 @@ function del(config, auth, className, objectId, context) { ); }) .catch(error => { + // Audit log failed delete + try { + config.auditLogController?.logDataDelete({ + auth, + req: { config }, + className, + objectId, + success: false, + error: error.message, + }); + } catch (auditError) { + config.loggerController.error('Audit logging error in rest.del', { error: auditError }); + } handleSessionMissingError(error, className, auth); }); }