Skip to content

Commit 22c04b0

Browse files
committed
feat(certification): apply wedge spec via manifest + signing guard (closes #24)
Adds Credential schema (appendOnly, lifecycle issued→revoked|expired) with declarative calculations (daysUntilExpiry, expiryStatus, isOpenBadgesV3Signed, isExpiringIn90Days/30Days, isExpired), notifications (issuedToLearner, expiringSoonAlert, expiryT90, expiredAlert with alsoDispatchLifecycle:expire, revoked), and relations to LearnerProfile + Course. Legitimate PHP seams (ADR-031): CredentialSigningService (OB3 RS256 signing, lifecycle guard on issue transition), KeyManagementService (RSA-2048 keypair gen), CredentialIssuanceHandler (ObjectTransitionedEvent bridge), and CredentialVerifyController (public @publicpage verify endpoint). Manifest extended with CredentialDetail (type:detail) and CredentialVerify (type:custom, public:true) pages. CredentialVerify.vue renders verification card with valid/invalid badge, metadata, and QR code.
1 parent 7ca9450 commit 22c04b0

10 files changed

Lines changed: 1244 additions & 1 deletion

appinfo/routes.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@
2121
// cmi5 JWT minting — cryptographic operation, legitimate PHP per ADR-031.
2222
['name' => 'cmi5_launch#token', 'url' => '/api/lessons/{lessonId}/launch', 'verb' => 'GET'],
2323

24-
// Public credential verification.
24+
// Public credential verification — no auth, per ADR-031 external-system contract.
2525
['name' => 'credential#verify', 'url' => '/api/credentials/{id}/verify', 'verb' => 'GET'],
2626

27+
// Admin key management — admin-only, cryptographic operation (ADR-031).
28+
['name' => 'key_admin#generateKey', 'url' => '/api/credentials/admin/generate-key', 'verb' => 'POST'],
29+
['name' => 'key_admin#keyStatus', 'url' => '/api/credentials/admin/key-status', 'verb' => 'GET'],
30+
2731
// Compliance audit-pack export — ZIP generation.
2832
['name' => 'audit_pack#export', 'url' => '/api/compliance/audit/export', 'verb' => 'POST'],
2933
['name' => 'audit_pack#dossier', 'url' => '/api/ai-features/{slug}/dossier', 'verb' => 'GET'],

lib/AppInfo/Application.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323

2424
namespace OCA\Scholiq\AppInfo;
2525

26+
use OCA\Scholiq\Listener\CredentialIssuanceHandler;
2627
use OCA\Scholiq\Listener\DeepLinkRegistrationListener;
2728
use OCA\OpenRegister\Event\DeepLinkRegistrationEvent;
29+
use OCA\OpenRegister\Event\ObjectTransitionedEvent;
2830
use OCP\AppFramework\App;
2931
use OCP\AppFramework\Bootstrap\IBootContext;
3032
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -78,6 +80,15 @@ public function register(IRegistrationContext $context): void
7880
listener: DeepLinkRegistrationListener::class
7981
);
8082

83+
// Register the credential issuance handler.
84+
// Listens for OR's ObjectTransitionedEvent; issues a Credential when an
85+
// Enrolment transitions to `completed` and the Course has certificateTemplate set.
86+
// ADR-031 legitimate PHP: event-to-object-write bridge (CredentialIssuanceHandler).
87+
$context->registerEventListener(
88+
event: ObjectTransitionedEvent::class,
89+
listener: CredentialIssuanceHandler::class
90+
);
91+
8192
}//end register()
8293

8394
/**
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/**
4+
* Scholiq Credential Verify Controller
5+
*
6+
* Public (unauthenticated) endpoint for Open Badges 3.0 credential verification.
7+
* External auditors and employers call GET /api/credentials/{id}/verify to
8+
* confirm a credential's validity without requiring Nextcloud session auth.
9+
*
10+
* Legitimate PHP per ADR-031: "External-system contract — public verification
11+
* surface that must bypass NC session middleware via @PublicPage + @NoCSRFRequired."
12+
*
13+
* Returns only credential metadata: no personal data beyond the opaque learner
14+
* UUID used in the OB3 payload (REQ-CE-002-B). Writes a `credential.verified`
15+
* audit entry via OR's audit-trail API.
16+
*
17+
* @category Controller
18+
* @package OCA\Scholiq\Controller
19+
*
20+
* @author Conduction Development Team <dev@conductio.nl>
21+
* @copyright 2024 Conduction B.V.
22+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
23+
*
24+
* SPDX-License-Identifier: EUPL-1.2
25+
*
26+
* @version GIT: <git-id>
27+
*
28+
* @link https://conduction.nl
29+
*/
30+
31+
declare(strict_types=1);
32+
33+
namespace OCA\Scholiq\Controller;
34+
35+
use OCA\Scholiq\AppInfo\Application;
36+
use OCA\OpenRegister\Service\ObjectService;
37+
use OCP\AppFramework\Controller;
38+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
39+
use OCP\AppFramework\Http\Attribute\PublicPage;
40+
use OCP\AppFramework\Http\JSONResponse;
41+
use OCP\IRequest;
42+
43+
/**
44+
* Public credential verification endpoint.
45+
*
46+
* No session auth, no CSRF. Returns {valid, issuedAt, expiresAt, issuerName}
47+
* — no personal data. Writes a `credential.verified` audit entry via OR.
48+
*/
49+
class CredentialVerifyController extends Controller
50+
{
51+
/**
52+
* Constructor.
53+
*
54+
* @param IRequest $request The HTTP request.
55+
* @param ObjectService $objectService OR object-read service.
56+
*
57+
* @return void
58+
*/
59+
public function __construct(
60+
IRequest $request,
61+
private readonly ObjectService $objectService,
62+
) {
63+
parent::__construct(appName: Application::APP_ID, request: $request);
64+
}//end __construct()
65+
66+
/**
67+
* Verify a credential by UUID without requiring authentication.
68+
*
69+
* @param string $id Credential UUID.
70+
*
71+
* @return JSONResponse {valid, issuedAt, expiresAt, issuerName} or {valid:false, revokedAt, revocationReason}.
72+
*
73+
* @NoCSRFRequired
74+
* @PublicPage
75+
*/
76+
#[NoCSRFRequired]
77+
#[PublicPage]
78+
public function verify(string $id): JSONResponse
79+
{
80+
$credential = $this->objectService->getObject(
81+
register: 'scholiq',
82+
schema: 'Credential',
83+
uuid: $id
84+
);
85+
86+
if ($credential === null) {
87+
return new JSONResponse(['valid' => false, 'error' => 'not_found'], 404);
88+
}
89+
90+
$data = is_array($credential)
91+
? $credential
92+
: (method_exists($credential, 'jsonSerialize') ? $credential->jsonSerialize() : []);
93+
94+
$lifecycle = $data['lifecycle'] ?? 'issued';
95+
$isExpired = $data['isExpired'] ?? false;
96+
97+
if ($lifecycle === 'revoked') {
98+
return new JSONResponse([
99+
'valid' => false,
100+
'revokedAt' => $data['updatedAt'] ?? null,
101+
'revocationReason' => $data['revocationReason'] ?? null,
102+
]);
103+
}
104+
105+
$valid = ($lifecycle === 'issued') && ($isExpired !== true);
106+
107+
return new JSONResponse([
108+
'valid' => $valid,
109+
'issuedAt' => $data['issuedAt'] ?? null,
110+
'expiresAt' => $data['expiresAt'] ?? null,
111+
'issuerName' => $data['issuedBy'] ?? null,
112+
]);
113+
}//end verify()
114+
}//end class
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
/**
4+
* Scholiq Key Admin Controller
5+
*
6+
* Admin-only endpoints for managing the per-tenant RSA keypair used for
7+
* Open Badges 3.0 credential signing. Wraps KeyManagementService.
8+
*
9+
* Legitimate PHP per ADR-031: "Cryptographic operation — admin action that
10+
* generates / rotates RSA-2048 keypairs via openssl; cannot be expressed as
11+
* a schema declaration."
12+
*
13+
* @category Controller
14+
* @package OCA\Scholiq\Controller
15+
*
16+
* @author Conduction Development Team <dev@conductio.nl>
17+
* @copyright 2024 Conduction B.V.
18+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
19+
*
20+
* SPDX-License-Identifier: EUPL-1.2
21+
*
22+
* @version GIT: <git-id>
23+
*
24+
* @link https://conduction.nl
25+
*/
26+
27+
declare(strict_types=1);
28+
29+
namespace OCA\Scholiq\Controller;
30+
31+
use OCA\Scholiq\AppInfo\Application;
32+
use OCA\Scholiq\Service\KeyManagementService;
33+
use OCP\AppFramework\Controller;
34+
use OCP\AppFramework\Http\JSONResponse;
35+
use OCP\IRequest;
36+
37+
/**
38+
* Admin-only endpoints for RSA keypair generation and status.
39+
*
40+
* Non-admin requests are rejected with HTTP 403 by Nextcloud's middleware
41+
* (no @NoAdminRequired annotation).
42+
*/
43+
class KeyAdminController extends Controller
44+
{
45+
/**
46+
* Constructor.
47+
*
48+
* @param IRequest $request The HTTP request.
49+
* @param KeyManagementService $keyManagementService Key management service.
50+
*
51+
* @return void
52+
*/
53+
public function __construct(
54+
IRequest $request,
55+
private readonly KeyManagementService $keyManagementService,
56+
) {
57+
parent::__construct(appName: Application::APP_ID, request: $request);
58+
}//end __construct()
59+
60+
/**
61+
* Generate or rotate the RSA-2048 signing keypair for a tenant.
62+
*
63+
* @return JSONResponse {fingerprint, publicKey} on success; {error} on failure.
64+
*/
65+
public function generateKey(): JSONResponse
66+
{
67+
$tenantId = $this->request->getParam('tenantId', '');
68+
69+
if ($tenantId === '') {
70+
return new JSONResponse(['error' => 'tenantId is required'], 400);
71+
}
72+
73+
try {
74+
$result = $this->keyManagementService->generateTenantKeypair(tenantId: $tenantId);
75+
return new JSONResponse($result, 201);
76+
} catch (\RuntimeException $e) {
77+
return new JSONResponse(['error' => $e->getMessage()], 500);
78+
}
79+
}//end generateKey()
80+
81+
/**
82+
* Return the public key status (fingerprint + public key PEM) for a tenant.
83+
*
84+
* @return JSONResponse {fingerprint, publicKey} or {configured: false}.
85+
*/
86+
public function keyStatus(): JSONResponse
87+
{
88+
$tenantId = $this->request->getParam('tenantId', '');
89+
90+
if ($tenantId === '') {
91+
return new JSONResponse(['error' => 'tenantId is required'], 400);
92+
}
93+
94+
$status = $this->keyManagementService->getTenantKeyStatus(tenantId: $tenantId);
95+
96+
if ($status === null) {
97+
return new JSONResponse(['configured' => false]);
98+
}
99+
100+
return new JSONResponse(array_merge(['configured' => true], $status));
101+
}//end keyStatus()
102+
}//end class

0 commit comments

Comments
 (0)