Certificate System
Tổng quan
Licensing service publish một snapshot đã ký, đã mã hoá của mỗi license — status, tier, features, config activation, và hết hạn — lên Redis bất cứ khi nào trạng thái license thay đổi. Các service khác (Commerce, Sale, Inventory, …) đọc snapshot này qua LicenseMiddleware từ @nx/core. Họ không bao giờ gọi licensing service để kiểm tra license runtime; chứng chỉ mang mọi thứ họ cần.
Đây là "offline-capable" theo nghĩa service consumer không cần round-trip đồng bộ tới licensing service cho mỗi request. Họ vẫn cần Redis.
Kiến trúc
Trong payload có gì
ILicenseCertificatePayload (từ @nx/core/services/license/certs/types.ts):
interface ILicenseCertificatePayload<FeaturesType = Record<string, unknown>> {
license: { id: string; key: string };
entity: { type: string; id: string }; // vd { type: 'merchants', id: '<merchantId>' }
status: string; // 'activated' | 'expired' | 'suspended' | 'revoked'
tier: string; // policy.type — '000_TRIAL' / '100_SUBSCRIPTION' / '200_PERPETUAL'
features: FeaturesType; // feature flag đã giải (đã áp override)
activation: { limit: number } | null; // config activation đã giải (override → policy → null)
expiresAt: string | null; // license.expiresAt (ISO) hoặc null nếu vĩnh viễn
issuedAt: string; // ISO timestamp lúc ký (KHÔNG phải license.issuedAt)
certExpiresAt: string; // lúc ký + APP_ENV_LICENSING_CERT_TTL_SECONDS
}issuedAt là thời điểm ký, không phải thời điểm phát hành license
publishCertificate() đặt payload.issuedAt = new Date().toISOString() ngay lúc dựng payload, nghĩa là nó đổi mỗi lần chứng chỉ được re-publish. Nếu bạn thực sự muốn ngày phát hành thật của license, đọc nó từ database (license.issuedAt) — nó không được giữ trong payload chứng chỉ.
Quy trình ký
Hiện thực bởi LicenseCertSignerHelper.sign({ payload, secret, privateKey }) trong packages/core/src/services/license/certs/signer.ts.
Metadata thuật toán lưu trong envelope là hằng CertificateDefinitions.FULL_FORM = 'aes-256-gcm+ed25519'. Verifier từ chối bất kỳ envelope nào có alg không khớp đúng chuỗi này.
Inputs
| Argument | Nguồn | Bắt buộc |
|---|---|---|
payload | Dựng bởi LicensingBaseService.publishCertificate() từ license, policy, features đã giải, và config activation đã giải | ✅ |
secret | APP_ENV_APPLICATION_SECRET (truyền vào bởi caller) — dùng làm khoá AES-256-GCM | ✅ |
privateKey | APP_ENV_LICENSING_ED25519_PRIVATE_KEY (chuỗi PEM) | ✅ |
Secret AES là application secret, không phải license key
Hiểu nhầm thường gặp: secret AES là APP_ENV_APPLICATION_SECRET, không phải license.key. Cùng secret được dùng bởi LicenseCertSignerHelper.sign() và LicenseCertVerifierHelper.verify(), nghĩa là mọi service consumer phải cấu hình cùng APP_ENV_APPLICATION_SECRET như licensing service đã ký chứng chỉ. Đây là một secret đối xứng bổ sung cho keypair Ed25519 bất đối xứng.
Envelope output
interface ICertificateEnvelope {
enc: string; // ciphertext AES-256-GCM (mã hoá chuỗi bởi AES helper)
sig: string; // chữ ký Ed25519, mã hoá base64url
alg: string; // 'aes-256-gcm+ed25519'
}Envelope được JSON-stringify rồi base64-encode. Chuỗi base64 là cái cuối cùng nằm trong license.certificate và trong Redis.
Quy trình publish
LicensingBaseService.publishCertificate({ license }):
- Đọc
APP_ENV_LICENSING_ED25519_PRIVATE_KEY. Nếu thiếu → log cảnh báo và return sớm (không sinh chứng chỉ). - Đọc
APP_ENV_APPLICATION_SECRET. Nếu thiếu → log cảnh báo và return sớm. - Đọc
APP_ENV_LICENSING_CERT_TTL_SECONDS(mặc định86400). findPolicy({ id: license.policyId })resolveFeatures({ policyId, featuresOverride: license.override?.features ?? null })resolveActivation({ policy, license })→{ limit }hoặcnull- Dựng payload (xem cảnh báo
issuedAtở trên). LicenseCertSignerHelper.getInstance().sign({ payload, secret, privateKey })licenseRepository.updateById({ id: license.id, data: { certificate } })- Nếu có Redis client bound,
redis.client.set('lic:certs:<entityType>:<entityId>', certificate, 'EX', certTtl)
Bỏ qua âm thầm khi thiếu config
Các nhánh "log cảnh báo và return" ở bước 1–2 nghĩa là môi trường sai cấu hình sẽ lặng lẽ phát hành license với không chứng chỉ. Consumer khi đó thấy null từ LicenseMiddleware (fail-open) và hành động như merchant không có license nào. Không có validate env vars này lúc startup.
Khi nào publish kích hoạt
| Trigger | Vì sao |
|---|---|
LicenseManagementService.issue() | Chứng chỉ ban đầu sau khi tạo |
LicenseManagementService.suspend() | Status đổi thành suspended |
LicenseManagementService.reinstate() | Status đổi lại activated |
LicenseManagementService.renew() | Status về activated và expiresAt mở rộng |
LicenseManagementService.revoke() | Status đổi thành revoked |
ValidationService.validate() (chỉ auto-expiry) | Status lật sang expired sau khi phát hiện qua-grace |
PATCH /licenses/{id} (update CRUD chuẩn — dùng để đặt override, ví dụ) không trigger re-publish. Tới khi một hành động vòng đời chạy, chứng chỉ cache giữ giá trị override cũ, dù POST /validation/validate (query database tươi mỗi lần gọi) đã phản ánh chúng.
Cache Redis
| Pattern key | Value | TTL |
|---|---|---|
lic:certs:{entityType}:{entityId} | Envelope base64 dưới dạng một chuỗi đơn | APP_ENV_LICENSING_CERT_TTL_SECONDS (mặc định 86400) |
Ví dụ:
lic:certs:merchants:01234567890123lic:certs:users:01234567890456
Value Redis là chuỗi thuần
Value cache chỉ là envelope base64 dưới dạng chuỗi — không phải object JSON như { certificate, licenseKey }. Không có licenseKey lưu riêng, vì xác minh không cần nó: secret AES verifier dùng là APP_ENV_APPLICATION_SECRET.
Xác minh
Hiện thực bởi LicenseCertVerifierHelper.verify({ certificate, secret, publicKey }):
- base64-decode → JSON-parse →
ICertificateEnvelope envelope.alg === 'aes-256-gcm+ed25519'— nếu không400 Bad Request- Dựng lại
Buffer('license:<enc>')vàEd25519.verify(message, publicKey, sig)— thất bại →401 Unauthorized(Certificate signature verification failed) aes.decrypt(envelope.enc, secret)→ JSON-parse plaintext →ILicenseCertificatePayload- Nếu
payload.certExpiresAt < now.toISOString()→401 Unauthorized(Certificate has expired) - Trả về payload
Verifier phải được cấp:
| Argument | Nguồn |
|---|---|
certificate | Envelope base64 từ Redis |
secret | APP_ENV_APPLICATION_SECRET (phải khớp với signer) |
publicKey | APP_ENV_LICENSING_ED25519_PUBLIC_KEY (PEM) |
Phía consumer: LicenseMiddleware
Nguồn: packages/core/src/middlewares/license/license.middleware.ts. Cung cấp bởi @nx/core cho bất kỳ service nào cần đọc license context cho user đã xác thực hiện tại.
class LicenseMiddleware extends BaseHelper implements IProvider<MiddlewareHandler> {
static readonly LICENSE_CONTEXT_KEY = 'license';
constructor(
private readonly publicKey: string, // APP_ENV_LICENSING_ED25519_PUBLIC_KEY
private readonly applicationSecret: string, // APP_ENV_APPLICATION_SECRET
private readonly redis: DefaultRedisHelper,
) { ... }
}Cách nó chạy
- Đọc user đã xác thực từ
context.get(Authentication.CURRENT_USER). Nếu vắng, bỏ qua vànext(). - Lấy
merchants: { id: string }[]vàuserId: string | undefined(từcurrentUser.sub ?? currentUser.id) khỏi auth context. - Song song:
- Cho mỗi merchant:
resolveCertificate({ entityType: 'merchants', entityId: m.id }) - Cho user (nếu có):
resolveCertificate({ entityType: 'users', entityId: userId })
- Cho mỗi merchant:
resolveCertificateđọclic:certs:<type>:<id>từ Redis và chạy verifier helper. Bất kỳ thất bại nào (Redis miss, lỗi deserialize, chữ ký thất bại, payload hết hạn) đều được bắt và trảnull— fail-open.- Đặt kết quả đã merge lên
contextdướiLicenseMiddleware.LICENSE_CONTEXT_KEY = 'license':
interface ILicenseContext {
merchants: Record<string, ILicenseCertificatePayload | null>;
user: ILicenseCertificatePayload | null;
}Đọc từ controller / service
const licenseCtx = context.get('license') as ILicenseContext | undefined;
const merchantLicense = licenseCtx?.merchants[merchantId] ?? null;
if (!merchantLicense) {
// Hoặc không có license, hoặc xác minh thất bại (xử lý như nhau — "unknown")
}
if (merchantLicense?.status !== 'activated') {
// Suspended / revoked / expired — chặn feature
}
if (merchantLicense?.tier === '000_TRIAL') {
// Áp hạn chế trial
}
const maxProducts = merchantLicense?.features['max_products'] as number | undefined;
if (maxProducts != null && currentCount >= maxProducts) {
// Cưỡng chế giới hạn
}Hệ quả fail-open
Middleware cố ý không bao giờ throw khi cert có vấn đề — Redis down hay xác minh thất bại đều cho null. Coi null là "unknown", không phải "unlicensed". Nếu cần cổng license cứng, làm kiểm tra rõ ràng trong business logic và chọn cách phản ứng khi value là null.
Biến môi trường
Bảng đầy đủ (khoá signer + khoá consumer khớp) nằm trong Configuration. Tóm lại: signer cần
APP_ENV_APPLICATION_SECRET+APP_ENV_LICENSING_ED25519_PRIVATE_KEY+APP_ENV_LICENSING_CERT_TTL_SECONDS; mọi consumer cần cùngAPP_ENV_APPLICATION_SECRETcộngAPP_ENV_LICENSING_ED25519_PUBLIC_KEYkhớp.
Trang liên quan
| Trang | Mô tả |
|---|---|
| Integration | Đường tin cậy xuyên service |
| API Events | Cert envelope + payload schema |
| License Lifecycle | Các thao tác trigger re-publish |
| Operations | Runbook xoay keypair |
| ADR-0001 | Vì sao Ed25519 + xác minh offline |