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.

| Method | Route | Operation | Permission | | ------ | ----- | --------- | ---------- | | GET | /agreements/documents | ListPrivacyLegalDocuments | Privacy.Agreements.Read | | GET | /agreements/status | GetPrivacyConsentStatus | Privacy.Agreements.Read | | GET | /agreements/history | ListPrivacyAgreementHistory | Privacy.Agreements.Read | | POST | /agreements/accept | AcceptPrivacyAgreement | Privacy.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.

| Method | Route | Operation | Permission | | ------ | ----- | --------- | ---------- | | POST | /legal-documents | CreateLegalDocument | Privacy.LegalDocuments.Create | | GET | /legal-documents/{id} | GetLegalDocument | Privacy.LegalDocuments.Read | | GET | /legal-documents | ListLegalDocumentVersions | Privacy.LegalDocuments.Read | | PUT | /legal-documents/{id} | UpdateLegalDocument | Privacy.LegalDocuments.Manage | | POST | /legal-documents/{id}/publish | PublishLegalDocument | Privacy.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.

| Event | Dispatched when | Consumer | | ----- | --------------- | -------- | | LegalAgreementAcceptedEto | User accepts a document version | Audit trail, cross-service sync | | LegalAgreementObsoleteEto | New version published, old archived | LegalDocumentObsoleteHandler → re-consent notifications | | LegalDocumentCacheInvalidatedEto | Document published or archived | All pods refresh ILegalDocumentRegistry cache |