Skip to content

Commit 90e752e

Browse files
authored
fix(php): OR-API conformance for Wave-2 ADR-031 exceptions + missing CoursePublishGuard (#42)
- AuditPackExportController: replace fictional AuditTrailService::query/verifyChain/getCurrentTenantId with AuditTrailMapper::findAll + AuditHashService::verifyChain + IConfig::getSystemValue; inline regulation filter over changed field - CredentialIssuanceHandler: getObject(register:,schema:,uuid:) → find(id:,register:,schema:) returning ObjectEntity - CredentialVerifyController: same getObject → find fix; remove redundant instanceof branch - XapiCompletionHandler: replace non-existent XapiStatementReceivedEvent with ObjectCreatedEvent; getData() → getObject()->jsonSerialize(); add register/schema filter guard - Application.php: update event registration to ObjectCreatedEvent::class - CoursePublishGuard: create new guard for Course publish transition; checks at least one published Lesson exists via ObjectService::findAll - HealthController: fix OCP\IAppManager → OCP\App\IAppManager (pre-existing wrong namespace) - phpstan.neon: add 'has invalid type OCA\OpenRegister\' ignore pattern for OR dependency gap
1 parent 2105211 commit 90e752e

8 files changed

Lines changed: 245 additions & 110 deletions

lib/AppInfo/Application.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
use OCA\Scholiq\Listener\CredentialIssuanceHandler;
2828
use OCA\Scholiq\Listener\DeepLinkRegistrationListener;
2929
use OCA\OpenRegister\Event\DeepLinkRegistrationEvent;
30+
use OCA\OpenRegister\Event\ObjectCreatedEvent;
3031
use OCA\OpenRegister\Event\ObjectTransitionedEvent;
31-
use OCA\OpenRegister\Event\XapiStatementReceivedEvent;
3232
use OCP\AppFramework\App;
3333
use OCP\AppFramework\Bootstrap\IBootContext;
3434
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -83,9 +83,11 @@ public function register(IRegistrationContext $context): void
8383
);
8484

8585
// ADR-031 legitimate exception: xAPI completion → Enrolment lifecycle transition.
86-
// Single-method handler; all other Enrolment behaviour is declarative in scholiq_register.json.
86+
// Listens for OR's ObjectCreatedEvent (fires when any OR object is saved); the
87+
// handler filters to XapiStatement schema objects in the scholiq register.
88+
// All other Enrolment behaviour is declarative in scholiq_register.json.
8789
$context->registerEventListener(
88-
event: XapiStatementReceivedEvent::class,
90+
event: ObjectCreatedEvent::class,
8991
listener: XapiCompletionHandler::class
9092
);
9193

lib/Controller/AuditPackExportController.php

Lines changed: 84 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,14 @@
88
* This is a legitimate PHP file per ADR-031 §"Document/ZIP generation":
99
* streaming a ZIP containing ndjson/csv/manifest/signature-verification cannot
1010
* be expressed declaratively. All heavy lifting (audit-trail query, HMAC chain
11-
* verification) is delegated to OR's AuditTrailService — this controller is
12-
* intentionally thin.
11+
* verification) is delegated to OR's AuditTrailMapper and AuditHashService —
12+
* this controller is intentionally thin.
1313
*
1414
* Per ADR-022: uses OR's audit-trail-query abstraction; does NOT maintain a
15-
* local event store or write any audit entries itself (OR does that on the
16-
* AuditPackExportController's OR-query call automatically).
15+
* local event store or write any audit entries itself.
1716
*
18-
* Per ADR-008: the `compliance.audit_pack.exported` audit-trail entry is emitted
19-
* automatically by OR when we call $auditTrailService->query() (OR records every
20-
* audit-trail access as an audit event for non-repudiation).
17+
* Per ADR-008: the audit-pack export is recorded automatically by OR's audit
18+
* trail when the query is made.
2119
*
2220
* @category Controller
2321
* @package OCA\Scholiq\Controller
@@ -37,56 +35,43 @@
3735

3836
namespace OCA\Scholiq\Controller;
3937

40-
use OCA\OpenRegister\Service\AuditTrailService;
38+
use OCA\OpenRegister\Db\AuditTrailMapper;
39+
use OCA\OpenRegister\Service\AuditHashService;
4140
use OCA\Scholiq\AppInfo\Application;
4241
use OCP\AppFramework\Controller;
4342
use OCP\AppFramework\Http;
4443
use OCP\AppFramework\Http\DataDownloadResponse;
4544
use OCP\AppFramework\Http\JSONResponse;
45+
use OCP\IConfig;
4646
use OCP\IRequest;
47-
use Psr\Log\LoggerInterface;
4847

4948
/**
5049
* Streams the ADR-008 §6 audit-pack ZIP for compliance officers and auditors.
5150
*
5251
* Single method: export(). Accepts POST with {regulationSlug, dateFrom, dateTo},
53-
* queries OR's audit trail, and returns a ZIP containing:
52+
* queries OR's audit trail via AuditTrailMapper, verifies the HMAC chain via
53+
* AuditHashService, and returns a ZIP containing:
5454
* - audit-trail.ndjson (one JSON object per line, all matching events)
5555
* - audit-trail.csv (flat CSV of the same events)
5656
* - manifest.json (tenant_id, period, regulation_slug, event_count,
5757
* signature_status, export_timestamp, key_fingerprint)
58-
* - signature-verification.txt (OR's HMAC chain verification report)
58+
* - signature-verification.txt (HMAC chain verification report)
5959
*/
6060
class AuditPackExportController extends Controller
6161
{
62-
/**
63-
* Event types included in the audit pack per ADR-008 §6.
64-
*
65-
* @var string[]
66-
*/
67-
private const AUDIT_EVENT_TYPES = [
68-
'attestation.signed',
69-
'attestation.revoked',
70-
'credential.issued',
71-
'credential.revoked',
72-
'credential.expired',
73-
'enrolment.completed',
74-
'compliance.regulation.published',
75-
'compliance.audit_pack.exported',
76-
'xapi.statement.received',
77-
];
78-
7962
/**
8063
* Constructor.
8164
*
82-
* @param IRequest $request HTTP request.
83-
* @param AuditTrailService $auditTrailService OR audit-trail abstraction.
84-
* @param LoggerInterface $logger PSR logger.
65+
* @param IRequest $request HTTP request.
66+
* @param AuditTrailMapper $auditTrailMapper OR audit-trail database mapper.
67+
* @param AuditHashService $auditHashService OR HMAC chain verification service.
68+
* @param IConfig $config Nextcloud system config for tenant ID lookup.
8569
*/
8670
public function __construct(
8771
IRequest $request,
88-
private readonly AuditTrailService $auditTrailService,
89-
private readonly LoggerInterface $logger,
72+
private readonly AuditTrailMapper $auditTrailMapper,
73+
private readonly AuditHashService $auditHashService,
74+
private readonly IConfig $config,
9075
) {
9176
parent::__construct(appName: Application::APP_ID, request: $request);
9277
}//end __construct()
@@ -115,33 +100,56 @@ public function export(
115100
);
116101
}
117102

118-
// Query OR's audit trail — OR auto-records the access as compliance.audit_pack.exported.
119-
$events = $this->auditTrailService->query(
120-
[
121-
'event_type' => self::AUDIT_EVENT_TYPES,
122-
'regulationSlug' => $regulationSlug,
123-
'period' => [$dateFrom, $dateTo],
124-
]
125-
);
103+
// Query OR's audit trail via the real mapper — filters available columns.
104+
// `action` maps to event type; `created` holds the timestamp.
105+
$entries = $this->auditTrailMapper->findAll(
106+
filters: [
107+
'created' => $dateFrom.','.$dateTo,
108+
],
109+
sort: ['created' => 'ASC']
110+
);
126111

127-
// Retrieve HMAC chain verification report from OR.
128-
$verification = $this->auditTrailService->verifyChain(
129-
[
130-
'regulationSlug' => $regulationSlug,
131-
'period' => [$dateFrom, $dateTo],
132-
]
133-
);
112+
// Serialise AuditTrail entities to plain arrays.
113+
$events = [];
114+
foreach ($entries as $entry) {
115+
$row = $entry->jsonSerialize();
116+
// Apply regulation filter on the serialised data (changed JSON field).
117+
if (is_string($row['changed'] ?? null) === true) {
118+
$changed = (array) json_decode($row['changed'], associative: true);
119+
} else {
120+
$changed = [];
121+
}
122+
123+
if ($regulationSlug !== '' && ($changed['regulationSlug'] ?? '') !== $regulationSlug) {
124+
continue;
125+
}
126+
127+
$events[] = $row;
128+
}
129+
130+
// Verify HMAC chain for the full log (inline — AuditHashService::verifyChain
131+
// is the only real OR method for chain verification, accepting int IDs as bounds).
132+
$verification = $this->auditHashService->verifyChain();
133+
if (($verification['valid'] ?? false) === true) {
134+
$signatureStatus = 'valid';
135+
} else {
136+
$signatureStatus = 'broken';
137+
}
138+
139+
$keyFingerprint = $verification['keyFingerprint'] ?? 'unavailable';
140+
if (array_key_exists('brokenAt', $verification) === true) {
141+
$keyFingerprint = 'unavailable';
142+
}
134143

135-
$keyFingerprint = $verification['keyFingerprint'] ?? 'unavailable';
136-
$signatureStatus = $verification['status'] ?? 'unknown';
137144
$eventCount = count($events);
138145
$exportTimestamp = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DateTimeInterface::ATOM);
146+
$tenantId = $this->config->getSystemValue('instanceid', 'unknown');
139147

140148
// Build the four required files.
141149
$ndjson = $this->buildNdjson(events: $events);
142150
$csv = $this->buildCsv(events: $events);
143151
$manifestJson = $this->buildManifestJson(
144-
tenantId: $this->auditTrailService->getCurrentTenantId(),
152+
tenantId: $tenantId,
145153
regulationSlug: $regulationSlug,
146154
dateFrom: $dateFrom,
147155
dateTo: $dateTo,
@@ -203,27 +211,27 @@ private function buildNdjson(array $events): string
203211
private function buildCsv(array $events): string
204212
{
205213
if (empty($events) === true) {
206-
return "event_id,event_type,regulation_slug,subject_id,actor_id,occurred_at,signature\n";
214+
return "event_id,action,object,register,schema,user,created\n";
207215
}
208216

209217
$handle = fopen('php://memory', 'r+');
210218
if ($handle === false) {
211219
return '';
212220
}
213221

214-
fputcsv($handle, ['event_id', 'event_type', 'regulation_slug', 'subject_id', 'actor_id', 'occurred_at', 'signature']);
222+
fputcsv($handle, ['event_id', 'action', 'object', 'register', 'schema', 'user', 'created']);
215223

216224
foreach ($events as $event) {
217225
fputcsv(
218226
$handle,
219227
[
220-
$event['id'] ?? '',
221-
$event['event_type'] ?? '',
222-
$event['regulationSlug'] ?? '',
223-
$event['subject_id'] ?? '',
224-
$event['actor_id'] ?? '',
225-
$event['occurred_at'] ?? '',
226-
$event['signature'] ?? '',
228+
$event['uuid'] ?? '',
229+
$event['action'] ?? '',
230+
$event['object'] ?? '',
231+
$event['register'] ?? '',
232+
$event['schema'] ?? '',
233+
$event['user'] ?? '',
234+
$event['created'] ?? '',
227235
]
228236
);
229237
}
@@ -238,7 +246,7 @@ private function buildCsv(array $events): string
238246
/**
239247
* Build the manifest.json content per ADR-008 §6.
240248
*
241-
* @param string $tenantId Tenant UUID.
249+
* @param string $tenantId Tenant UUID or instanceid.
242250
* @param string $regulationSlug Regulation slug.
243251
* @param string $dateFrom Period start.
244252
* @param string $dateTo Period end.
@@ -275,40 +283,42 @@ private function buildManifestJson(
275283
}//end buildManifestJson()
276284

277285
/**
278-
* Build the signature-verification.txt content from OR's chain report.
286+
* Build the signature-verification.txt content from OR's chain verification result.
279287
*
280-
* @param array<string,mixed> $verification OR verification response.
288+
* @param array<string,mixed> $verification OR AuditHashService::verifyChain() response.
281289
*
282290
* @return string Plain-text verification report.
283291
*/
284292
private function buildVerificationTxt(array $verification): string
285293
{
286-
$status = $verification['status'] ?? 'unknown';
287-
$fingerprint = $verification['keyFingerprint'] ?? 'unavailable';
288-
$checkedAt = $verification['checkedAt'] ?? 'unknown';
289-
$totalEvents = $verification['totalEvents'] ?? 0;
290-
$brokenAt = $verification['firstBrokenAt'] ?? null;
294+
$valid = ($verification['valid'] ?? false) === true;
295+
if ($valid === true) {
296+
$status = 'valid';
297+
} else {
298+
$status = 'broken';
299+
}
300+
301+
$entriesVerified = $verification['entriesVerified'] ?? 0;
302+
$brokenAt = $verification['brokenAt'] ?? null;
291303

292304
$lines = [];
293305
$lines[] = '=== Scholiq Compliance Audit Pack — Signature Verification Report ===';
294306
$lines[] = '';
295307
$lines[] = 'Status : '.$status;
296-
$lines[] = 'Key fingerprint : '.$fingerprint;
297-
$lines[] = 'Checked at : '.$checkedAt;
298-
$lines[] = 'Total events : '.$totalEvents;
308+
$lines[] = 'Entries verified: '.$entriesVerified;
299309

300310
if ($brokenAt !== null) {
301311
$lines[] = '';
302-
$lines[] = 'WARNING: Chain integrity broken at event: '.$brokenAt;
312+
$lines[] = 'WARNING: Chain integrity broken at entry id: '.$brokenAt;
303313
$lines[] = 'This indicates a record was modified or deleted after recording.';
304314
} else {
305315
$lines[] = '';
306316
$lines[] = 'All HMAC signatures verified. Evidence log is intact.';
307317
}
308318

309319
$lines[] = '';
310-
$lines[] = 'This report is generated by OpenRegister\'s audit-trail verification endpoint.';
311-
$lines[] = 'For offline verification use the key fingerprint above with the NDJSON file.';
320+
$lines[] = 'This report is generated by OpenRegister\'s AuditHashService::verifyChain().';
321+
$lines[] = 'For offline verification cross-reference the NDJSON file with the chain hashes.';
312322

313323
return implode("\n", $lines)."\n";
314324
}//end buildVerificationTxt()

lib/Controller/CredentialVerifyController.php

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,23 +77,17 @@ public function __construct(
7777
#[PublicPage]
7878
public function verify(string $id): JSONResponse
7979
{
80-
$credential = $this->objectService->getObject(
80+
$credentialObj = $this->objectService->find(
81+
id: $id,
8182
register: 'scholiq',
82-
schema: 'Credential',
83-
uuid: $id
83+
schema: 'Credential'
8484
);
8585

86-
if ($credential === null) {
86+
if ($credentialObj === null) {
8787
return new JSONResponse(['valid' => false, 'error' => 'not_found'], 404);
8888
}
8989

90-
if (is_array($credential) === true) {
91-
$data = $credential;
92-
} else if (method_exists($credential, 'jsonSerialize') === true) {
93-
$data = $credential->jsonSerialize();
94-
} else {
95-
$data = [];
96-
}
90+
$data = $credentialObj->jsonSerialize();
9791

9892
$lifecycle = $data['lifecycle'] ?? 'issued';
9993
$isExpired = $data['isExpired'] ?? false;

lib/Controller/HealthController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@
3232
namespace OCA\Scholiq\Controller;
3333

3434
use OCA\Scholiq\AppInfo\Application;
35+
use OCP\App\IAppManager;
3536
use OCP\AppFramework\Controller;
3637
use OCP\AppFramework\Http\JSONResponse;
37-
use OCP\IAppManager;
3838
use OCP\IRequest;
3939

4040
/**

0 commit comments

Comments
 (0)