Skip to content

Commit 2b714e9

Browse files
authored
Merge pull request #577 from OmniZlatoon/compliance
feat: Implement Compliance Report Automation
2 parents 79e1391 + beffd01 commit 2b714e9

13 files changed

Lines changed: 493 additions & 0 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Monthly Compliance Report
2+
3+
on:
4+
schedule:
5+
- cron: '0 0 1 * *' # first day of month, midnight UTC
6+
workflow_dispatch: # manual trigger
7+
8+
jobs:
9+
generate-report:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout repository
14+
uses: actions/checkout@v3
15+
16+
- name: Set up Rust
17+
uses: dtolnay/rust-toolchain@stable
18+
19+
- name: Install Soroban CLI
20+
run: cargo install --locked soroban-cli --version 21.5.0
21+
22+
- name: Install Pandoc
23+
run: sudo apt-get update && sudo apt-get install -y pandoc
24+
25+
- name: Set up AWS credentials
26+
uses: aws-actions/configure-aws-credentials@v1
27+
with:
28+
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
29+
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
30+
aws-region: us-east-1 # Adjust as needed
31+
32+
- name: Run Compliance Report
33+
env:
34+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
35+
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET || 'streminderminds-compliance-reports' }}
36+
run: make compliance-report
37+
38+
- name: Upload Report Artifacts
39+
uses: actions/upload-artifact@v3
40+
with:
41+
name: compliance-report-${{ github.sha }}
42+
path: target/compliance/

Makefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ help:
6060
@echo " $(RED)perf-baseline$(NC) - Save performance baseline"
6161
@echo " $(RED)load-test$(NC) - Run configurable contract load tests and write reports"
6262
@echo " $(RED)load-test-ci$(NC) - Run bounded load tests with CI-safe defaults"
63+
@echo " $(RED)compliance-report$(NC) - Generate automated compliance report"
6364
@echo ""
6465
@echo "Examples:"
6566
@echo " make e2e-test # Full E2E test cycle"
@@ -442,3 +443,13 @@ coverage-dashboard: test-coverage-comprehensive
442443
chmod +x ./scripts/coverage-dashboard.sh
443444
./scripts/coverage-dashboard.sh
444445
@echo "$(GREEN)[COVERAGE]$(NC) Dashboard generated: target/coverage-dashboard/index.html"
446+
447+
# ─────────────────────────────────────────────────────────────
448+
# Compliance Reporting
449+
# ─────────────────────────────────────────────────────────────
450+
451+
# Generate automated compliance report
452+
compliance-report:
453+
@echo "$(RED)[COMPLIANCE]$(NC) Generating compliance report..."
454+
chmod +x ./scripts/generate_compliance_report.sh
455+
./scripts/generate_compliance_report.sh

api/src/routes/admin.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Router, Request, Response } from "express";
2+
import { authenticate, requireScope } from "../middleware/auth";
3+
import { complianceService } from "../services/complianceService";
4+
import { sendSuccess, sendLocalizedError } from "../utils/response";
5+
import { logger } from "../logger";
6+
7+
const router = Router();
8+
9+
/**
10+
* POST /api/v1/admin/compliance-report
11+
*
12+
* Trigger manual compliance report generation.
13+
* Restricted to users with 'compliance' scope.
14+
*/
15+
router.post(
16+
"/compliance-report",
17+
authenticate,
18+
requireScope("compliance"),
19+
async (req: Request, res: Response) => {
20+
try {
21+
logger.info("Admin triggered manual compliance report", {
22+
userId: req.auth?.sub,
23+
requestId: req.requestId
24+
});
25+
26+
const reportUrl = await complianceService.triggerReport();
27+
28+
sendSuccess(
29+
res,
30+
{
31+
message: "Compliance report generation started successfully",
32+
reportUrl,
33+
timestamp: new Date().toISOString()
34+
},
35+
202,
36+
req.requestId
37+
);
38+
} catch (error) {
39+
logger.error("Failed to trigger compliance report", { error });
40+
sendLocalizedError(
41+
req,
42+
res,
43+
500,
44+
"COMPLIANCE_TRIGGER_FAILED",
45+
"Failed to trigger compliance report generation"
46+
);
47+
}
48+
}
49+
);
50+
51+
export default router;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { spawn } from "child_process";
2+
import path from "path";
3+
import { logger } from "../logger";
4+
5+
export class ComplianceService {
6+
/**
7+
* Triggers the compliance report generation script.
8+
* Returns a promise that resolves with the report URL if successful.
9+
*/
10+
async triggerReport(): Promise<string> {
11+
return new Promise((resolve, reject) => {
12+
const scriptPath = path.resolve(__dirname, "../../../scripts/generate_compliance_report.sh");
13+
const year = new Date().getFullYear().toString();
14+
const month = (new Date().getMonth() + 1).toString().padStart(2, "0");
15+
16+
logger.info("Triggering compliance report generation", { scriptPath, year, month });
17+
18+
const child = spawn("bash", [scriptPath], {
19+
env: {
20+
...process.env,
21+
YEAR: year,
22+
MONTH: month,
23+
},
24+
});
25+
26+
let output = "";
27+
child.stdout.on("data", (data) => {
28+
output += data.toString();
29+
});
30+
31+
child.stderr.on("data", (data) => {
32+
logger.error("Compliance script error output", { stderr: data.toString() });
33+
});
34+
35+
child.on("close", (code) => {
36+
if (code === 0) {
37+
// Parse output for the S3 URL (assuming the script logs it at the end)
38+
const match = output.match(/s3:\/\/[\w.-]+\/.+\.pdf/);
39+
const reportUrl = match ? match[0] : `s3://streminderminds-compliance-reports/${year}/${month}/report_${year}_${month}.pdf`;
40+
41+
logger.info("Compliance report generated successfully", { reportUrl });
42+
resolve(reportUrl);
43+
} else {
44+
logger.error("Compliance report generation failed", { exitCode: code });
45+
reject(new Error(`Compliance script failed with exit code ${code}`));
46+
}
47+
});
48+
});
49+
}
50+
}
51+
52+
export const complianceService = new ComplianceService();
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#[cfg(test)]
2+
mod tests {
3+
use crate::roles::{RoleLevel, Permission};
4+
use crate::permissions::RolePermissions;
5+
use soroban_sdk::{Env, Vec};
6+
7+
#[test]
8+
fn test_compliance_admin_role_level() {
9+
let level = RoleLevel::ComplianceAdmin;
10+
assert_eq!(level.to_u32(), 6);
11+
assert_eq!(RoleLevel::from_u32(6), Some(RoleLevel::ComplianceAdmin));
12+
}
13+
14+
#[test]
15+
fn test_compliance_admin_permissions() {
16+
let env = Env::default();
17+
let permissions = RolePermissions::compliance_admin_permissions(&env);
18+
19+
assert!(permissions.contains(&Permission::GenerateComplianceReport));
20+
assert!(permissions.contains(&Permission::ViewAudit));
21+
assert!(permissions.contains(&Permission::ViewSystemStats));
22+
}
23+
24+
#[test]
25+
fn test_admin_can_grant_compliance_admin() {
26+
let admin = RoleLevel::Admin;
27+
let compliance_admin = RoleLevel::ComplianceAdmin;
28+
29+
// Admin (4) can grant ComplianceAdmin (6) due to explicit logic in can_grant
30+
assert!(admin.can_grant(&compliance_admin));
31+
}
32+
33+
#[test]
34+
fn test_super_admin_can_grant_compliance_admin() {
35+
let super_admin = RoleLevel::SuperAdmin;
36+
let compliance_admin = RoleLevel::ComplianceAdmin;
37+
38+
assert!(super_admin.can_grant(&compliance_admin));
39+
}
40+
41+
#[test]
42+
fn test_moderator_cannot_grant_compliance_admin() {
43+
let moderator = RoleLevel::Moderator;
44+
let compliance_admin = RoleLevel::ComplianceAdmin;
45+
46+
assert!(!moderator.can_grant(&compliance_admin));
47+
}
48+
}

contracts/shared/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,5 @@ mod logger_tests;
4646
pub mod monitoring_tests;
4747
#[cfg(test)]
4848
pub mod performance_tests;
49+
#[cfg(test)]
50+
mod compliance_tests;

contracts/shared/src/permissions.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ impl RolePermissions {
102102
permissions
103103
}
104104

105+
/// Get default permissions for a ComplianceAdmin role
106+
pub fn compliance_admin_permissions(env: &Env) -> Vec<Permission> {
107+
let mut permissions = Vec::new(env);
108+
permissions.push_back(Permission::GenerateComplianceReport);
109+
permissions.push_back(Permission::ViewAudit);
110+
permissions.push_back(Permission::ViewSystemStats);
111+
permissions.push_back(Permission::ViewAllUsers);
112+
permissions.push_back(Permission::ViewAllCourses);
113+
permissions.push_back(Permission::ViewAllCertificates);
114+
permissions
115+
}
116+
105117
/// Get permissions for a specific role level
106118
pub fn get_permissions_for_level(env: &Env, level: &RoleLevel) -> Vec<Permission> {
107119
match level {
@@ -110,6 +122,7 @@ impl RolePermissions {
110122
RoleLevel::Instructor => Self::instructor_permissions(env),
111123
RoleLevel::Admin => Self::admin_permissions(env),
112124
RoleLevel::SuperAdmin => Self::super_admin_permissions(env),
125+
RoleLevel::ComplianceAdmin => Self::compliance_admin_permissions(env),
113126
}
114127
}
115128

contracts/shared/src/roles.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub enum RoleLevel {
99
Instructor = 3,
1010
Admin = 4,
1111
SuperAdmin = 5,
12+
ComplianceAdmin = 6,
1213
}
1314

1415
impl RoleLevel {
@@ -19,6 +20,7 @@ impl RoleLevel {
1920
3 => Some(RoleLevel::Instructor),
2021
4 => Some(RoleLevel::Admin),
2122
5 => Some(RoleLevel::SuperAdmin),
23+
6 => Some(RoleLevel::ComplianceAdmin),
2224
_ => None,
2325
}
2426
}
@@ -30,14 +32,27 @@ impl RoleLevel {
3032
RoleLevel::Instructor => 3,
3133
RoleLevel::Admin => 4,
3234
RoleLevel::SuperAdmin => 5,
35+
RoleLevel::ComplianceAdmin => 6,
3336
}
3437
}
3538

3639
pub fn can_grant(&self, target_role: &RoleLevel) -> bool {
40+
if *self == RoleLevel::SuperAdmin {
41+
return true;
42+
}
43+
if *self == RoleLevel::Admin && *target_role == RoleLevel::ComplianceAdmin {
44+
return true;
45+
}
3746
self.to_u32() > target_role.to_u32()
3847
}
3948

4049
pub fn can_revoke(&self, target_role: &RoleLevel) -> bool {
50+
if *self == RoleLevel::SuperAdmin {
51+
return true;
52+
}
53+
if *self == RoleLevel::Admin && *target_role == RoleLevel::ComplianceAdmin {
54+
return true;
55+
}
4156
self.to_u32() >= target_role.to_u32()
4257
}
4358

@@ -48,6 +63,8 @@ impl RoleLevel {
4863
| (RoleLevel::Admin, _)
4964
| (RoleLevel::Instructor, Permission::ViewAudit)
5065
| (RoleLevel::Moderator, Permission::ViewAudit)
66+
| (RoleLevel::ComplianceAdmin, Permission::GenerateComplianceReport)
67+
| (RoleLevel::ComplianceAdmin, Permission::ViewAudit)
5168
)
5269
}
5370
}
@@ -165,6 +182,9 @@ pub enum Permission {
165182
ViewSystemStats,
166183
ViewAudit,
167184

185+
// Compliance
186+
GenerateComplianceReport,
187+
168188
// Dynamic permission
169189
Custom(soroban_sdk::Symbol),
170190
}
@@ -201,6 +221,7 @@ impl Permission {
201221
Permission::ViewAllUsers => "ViewAllUsers",
202222
Permission::ViewSystemStats => "ViewSystemStats",
203223
Permission::ViewAudit => "ViewAudit",
224+
Permission::GenerateComplianceReport => "GenerateComplianceReport",
204225
Permission::Custom(_) => "Custom",
205226
}
206227
}

docs/COMPLIANCE_REPORT.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Compliance Report Automation
2+
3+
## Overview
4+
This system automates the generation of audit-ready compliance reports for the StrellerMinds platform. It aggregates on-chain events from smart contracts and off-chain access logs from the API server.
5+
6+
## Components
7+
8+
### 1. Smart Contract Role: `ComplianceAdmin`
9+
A new privileged role `ComplianceAdmin` has been added to the RBAC system. This role has the `GenerateComplianceReport` permission.
10+
- **Permissions:** `GenerateComplianceReport`, `ViewAudit`, `ViewSystemStats`, `ViewAllUsers`, `ViewAllCourses`, `ViewAllCertificates`.
11+
- **Granting:** The `Admin` role can grant the `ComplianceAdmin` role.
12+
13+
### 2. API Endpoint: `POST /api/v1/admin/compliance-report`
14+
A protected endpoint that allows an authorized compliance administrator to trigger report generation manually.
15+
- **Authentication:** Bearer JWT required.
16+
- **Scope:** Must have the `compliance` scope in the JWT.
17+
18+
### 3. Generation Script: `scripts/generate_compliance_report.sh`
19+
The core logic for data aggregation and report formatting.
20+
- **On-chain:** Uses `soroban contract events` to pull contract activity.
21+
- **Off-chain:** Pulls `audit_logs` from the PostgreSQL database.
22+
- **Formatting:** Uses Markdown templates and `pandoc` to generate HTML/PDF reports.
23+
- **Storage:** Uploads generated artifacts to the configured S3 bucket.
24+
25+
### 4. Scheduled Workflow: `.github/workflows/compliance-report.yml`
26+
A GitHub Action that runs automatically on the first day of every month at midnight UTC.
27+
28+
## Configuration
29+
30+
The following environment variables/secrets are required for the automated job:
31+
- `DATABASE_URL`: Connection string for the production PostgreSQL database.
32+
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`: Credentials for S3 upload.
33+
- `AWS_S3_BUCKET`: The destination bucket (default: `streminderminds-compliance-reports`).
34+
35+
## Manual Execution
36+
To generate a report manually from the command line:
37+
```bash
38+
make compliance-report YEAR=2024 MONTH=05
39+
```
40+
41+
The reports will be saved in `target/compliance/YEAR/MONTH/`.

0 commit comments

Comments
 (0)