Skip to content

Legal Agreements

When a legal document changes (privacy policy update, new ToS version), every user who accepted the old version must re-consent. GDPR Art. 7 requires proof of consent for each version. Granit tracks which version each user accepted and triggers re-consent flows automatically when a new version is published.

For simple use cases, register documents at startup:

privacy.RegisterDocument("privacy-policy", "2.1", "Privacy Policy");
privacy.RegisterDocument("terms-of-service", "1.0", "Terms of Service");

Provide a store implementation to persist agreement records:

privacy.UseLegalAgreementStore<EfCoreLegalAgreementStore>();

ILegalAgreementChecker verifies if a user has signed the current version of a document — useful for middleware that blocks access until consent is renewed after a policy update.

For SaaS applications where administrators need to update legal documents without redeployment, use Granit.Privacy.EntityFrameworkCore:

builder.AddGranitPrivacyEntityFrameworkCore(options =>
options.UseNpgsql(connectionString));

This enables the LegalDocument entity — a VersionedWorkflowEntity with full lifecycle management:

  • Draft — being edited by an administrator
  • Published — the active version users must consent to
  • Archived — superseded by a newer version, preserved for audit trail

When a new version is published, the previous version is auto-archived in a single transaction and LegalAgreementObsoleteEto is dispatched to trigger re-consent notifications to all affected users.

Content rendering (soft-dependency on Templating)

Section titled “Content rendering (soft-dependency on Templating)”

Set TemplateName on the document (e.g., "Legal.PrivacyPolicy") to render multi-culture HTML content via Granit.Templating. If Templating is not loaded, the document is file-only (DocumentBlobId via BlobStorage) or the application handles rendering itself.

The ILegalDocumentRegistry is backed by a ConcurrentDictionary cache. When a document is published, LegalDocumentCacheInvalidatedEto is dispatched via Wolverine so all pods refresh their cache — no stale versions served.

MethodRouteOperationPermission
GET/agreements/documentsListPrivacyLegalDocumentsPrivacy.Agreements.Read
GET/agreements/statusGetPrivacyConsentStatusPrivacy.Agreements.Read
GET/agreements/historyListPrivacyAgreementHistoryPrivacy.Agreements.Read
POST/agreements/acceptAcceptPrivacyAgreementPrivacy.Agreements.Create

The accept endpoint validates that the submitted version matches the current version (rejects stale consent with 422), checks for duplicate acceptance (409), captures and pseudonymizes the client IP for the audit trail.

MethodRouteOperationPermission
POST/legal-documentsCreateLegalDocumentPrivacy.LegalDocuments.Create
GET/legal-documents/{id}GetLegalDocumentPrivacy.LegalDocuments.Read
GET/legal-documentsListLegalDocumentVersionsPrivacy.LegalDocuments.Read
PUT/legal-documents/{id}UpdateLegalDocumentPrivacy.LegalDocuments.Manage
POST/legal-documents/{id}/publishPublishLegalDocumentPrivacy.LegalDocuments.Manage

Publishing a draft auto-archives the previous published version and dispatches LegalAgreementObsoleteEto. The notification handler streams affected users via IAsyncEnumerable in batches of 1000 to avoid memory pressure on large tenants.

EventDispatched whenConsumer
LegalAgreementAcceptedEtoUser accepts a document versionAudit trail, cross-service sync
LegalAgreementObsoleteEtoNew version published, old archivedLegalDocumentObsoleteHandler → re-consent notifications
LegalDocumentCacheInvalidatedEtoDocument published or archivedAll pods refresh ILegalDocumentRegistry cache