Documents — Folder/Document Management with Path-Based ACL
Granit.Documents is the framework’s document-management module: hierarchical
folders, versioned documents, and a share-based ACL that inherits down the
folder tree via the materialised path. It sits on top of Granit.BlobStorage
for the binary plane and on Granit.MultiTenancy for tenant isolation.
This page focuses on the share ACL and inheritance semantics introduced by feature F6. Module overview, folder tree mechanics, and version model are covered by ADR-052.
Share ACL model
Section titled “Share ACL model”A DocumentShare is a single ACL grant. Every share is one of:
| Target | Grantee | Permission | Inheritance |
|---|---|---|---|
| Folder | User / Role / Group | Read / Edit / Manage | Inherits to descendants when IsDefault = true |
| Document | User / Role / Group | Read / Edit / Manage | No inheritance (single document) |
Two REST endpoints — one nested under each target:
POST /folders/{folderId}/sharesPOST /documents/{documentId}/sharesBody:
{ "granteeType": "User", "granteeId": "1f6a2b9d-...-...-...", "permission": "Edit", "isDefault": true, "expiresAt": null}The isDefault flag is only meaningful for folder shares — document shares
ignore it. Defaulting to true matches the most common user expectation: a
folder share applies to everything inside.
Path-based inheritance
Section titled “Path-based inheritance”When the resolver computes the effective permission for a (documentId, principal)
pair it considers:
- Direct shares on the document.
- Shares on the document’s folder.
- Shares on every ancestor folder up to (but excluding) the tenant root.
The check uses the materialised Folder.Path column — a tree like:
/ (tenant root)└── /Contracts (A) └── /Contracts/2026 (B) └── /Contracts/2026/Client-X (C) └── invoice.pdfA folder share on A with IsDefault = true confers its permission to
invoice.pdf because /Contracts is a path-prefix of
/Contracts/2026/Client-X. Granting the same share on B would still cover
invoice.pdf but no longer covers documents directly in A.
When several grants apply to the same document — direct, ancestor, and via
multiple roles or groups the principal carries — the highest permission
wins (Manage > Edit > Read). Expired grants (ExpiresAt in the past)
are ignored.
Worked example
Section titled “Worked example”| Grant | Target | Permission | Visible on invoice.pdf? |
|---|---|---|---|
| Share on A (IsDefault=true) for User U | Folder | Read | Yes — U sees Read |
| Share on C for Role R (U is in R) | Folder | Edit | Yes — U sees Edit (highest wins) |
| Share on B for Group G (U not in G) | Folder | Manage | No — U is not a grantee |
| Share on sibling /Other for U | Folder | Manage | No — /Other is not an ancestor of C |
Path-prefix is segment-aware
Section titled “Path-prefix is segment-aware”The resolver expands the document folder path into the full ancestor list
(/Contracts, /Contracts/2026, /Contracts/2026/Client-X) and matches by
exact equality, not by string prefix. A folder named /Contract (singular)
is not considered an ancestor of /Contracts/2026.
Phase-1 inheritance — IsDefault is persisted, always inherits
Section titled “Phase-1 inheritance — IsDefault is persisted, always inherits”In phase 1 (the current release), every folder share inherits to descendants.
The IsDefault flag is persisted on the row and threaded through the API but
the resolver does not filter on it — so a folder share with
IsDefault = false still inherits today.
The flag is reserved for phase 2: a non-inherited folder share that grants a “membership-only” capability (e.g., the right to list a folder without inheriting access to its descendants). Persisting the flag now keeps the schema and API stable across the phase boundary.
Caution. Phase 1 treats
IsDefault = falseandIsDefault = trueidentically at resolution time. Don’t rely onIsDefault = falseto limit visibility — until phase 2 ships, the only way to scope a grant tightly is to create it on the specific folder or document rather than on an ancestor.
FusionCache layer
Section titled “FusionCache layer”The resolver is wrapped by a FusionCache decorator (F6.3) that caches each
(tenantId, documentId, principal-grantee-set) answer with a tag set:
acl:doc:{documentId}— direct document tag.acl:folder:{folderId}— one tag per ancestor folder visited during resolution.acl:all— tenant-wide tag for bulk wipes.
Share grant / revoke and folder path-change events invalidate by tag through
AclCacheInvalidationHandler (Wolverine local bus). Folder moves bulk-wipe via
acl:all because a path change shifts the ancestor set of every descendant.
TTL defaults to 5 minutes (GranitDocumentsOptions.AclCacheTtl); the layer can
be disabled with AclCacheEnabled = false. Cache hits / misses are recorded on
granit.documents.acl.cache.hits / .misses, tagged with tenant_id.
Permissions
Section titled “Permissions”Coarse permissions complement per-row shares — a caller needs both the framework permission and a matching share to access a document:
| Permission | Required for |
|---|---|
| Documents.Documents.Read | Read a document the caller has a share on |
| Documents.Documents.Manage | Create / replace / delete documents |
| Documents.Folders.Read | List folder contents |
| Documents.Folders.Manage | Create / move / rename / delete folders |
| Documents.Shares.Read | List share rows on a folder or document |
| Documents.Shares.Manage | Grant / revoke shares |
Storage quotas (F7)
Section titled “Storage quotas (F7)”Each tenant has exactly one TenantStorageQuota row tracking UsageBytes versus
LimitBytes. The row is created lazily on the first byte stored
(ITenantQuotaService.EnsureTenantQuotaAsync), seeded from
GranitDocumentsOptions.DefaultTenantQuotaBytes (5 GB by default).
The write path is atomic — IncrementAsync, DecrementAsync, and TryReserveAsync
each issue a single ExecuteUpdateAsync round-trip. TryReserveAsync carries a
WHERE UsageBytes + @delta <= LimitBytes predicate, so concurrent uploads from
the same tenant cannot race past the limit. Hosts override the per-tenant cap
through the F7.3 admin endpoint or through the LimitBytes column directly.
UsageBytes is the source of truth for the
Granit.Documents.TotalStorageUsedMetric (F11) and is reconciled to the actual
sum of DocumentVersion.SizeBytes by the F9.3 background job.
Trash + permanent delete (F8)
Section titled “Trash + permanent delete (F8)”Documents have a three-state lifecycle: Active → Trashed → PermanentlyDeleted.
- Trash (
IDocumentService.TrashAsync) is reversible. The aggregate row stays;TrashedAtis set; the document disappears from the list endpoints. - Restore (
RestoreAsync) brings a trashed document back. If the parent folder is itself trashed, callers must restore the folder first. - Permanent delete (
PermanentlyDeleteAsync, F8.2) tombstones the row atStatus = PermanentlyDeletedfor the GDPR / ISO 27001 audit trail. Every version’sBlobDescriptoris transitioned toBlobStatus.DeletedviaIBlobStorage.DeleteAsync; the byte count is decremented from the tenant’s storage quota.
The trash list endpoint (GET /documents/trash) exposes
daysUntilPermanentDeletion = ceil((trashedAt + retentionDays - now).TotalDays),
so the UI can render a retention countdown without re-implementing the formula.
Background jobs (F9)
Section titled “Background jobs (F9)”Granit.Documents.BackgroundJobs ships three recurring jobs on top of
Granit.BackgroundJobs:
| Job | Cron | Purpose |
|---|---|---|
documents-orphan-cleanup | 0 * * * * (hourly) | Cleans blobs stuck in Pending / Uploading after 24 h via IBlobStorage.CleanupOrphansAsync (F9.1). |
documents-empty-trash | 0 3 * * * (nightly 03:00 UTC) | Promotes trashed documents older than TrashRetentionDays to PermanentlyDeleted (F9.2). |
documents-quota-recompute | 0 4 * * 0 (Sundays 04:00 UTC) | Reconciles TenantStorageQuota.UsageBytes with the authoritative version-sum (F9.3). |
All three delegate to IDocumentMaintenanceService (registered by
Granit.Documents.EntityFrameworkCore). Hosts that don’t reference the
EFC companion still pull in the contract through
Granit.Documents.BackgroundJobs, so the jobs are skip-safe when the impl is
absent — but the recurring schedule will fire and log the missing dependency.
Notifications (F10)
Section titled “Notifications (F10)”Granit.Documents.Notifications provides four notification types and two
auto-wired handlers on top of Granit.Notifications:
| Notification | Trigger | Handler? |
|---|---|---|
documents.shared | DocumentShareGrantedEvent (User grantee only) | Yes — DocumentSharedHandler |
documents.share_revoked | DocumentShareRevokedEvent (User grantee only) | Yes — DocumentShareRevokedHandler |
documents.quota_warning | host-published when usage crosses the early-warning threshold | No — host publishes |
documents.quota_exceeded | host-published when an upload is rejected for quota | No — host publishes |
Templates ship as embedded HTML in EN + FR (the framework baseline); other
cultures are generated by scripts/translate-templates.py with the
<!-- AUTO-TRANSLATED --> marker. Role and group fan-out is the host’s
responsibility — the framework handlers fire only for ShareGranteeType.User
because the recipient list for role / group grants depends on the host’s
identity layer.
Declarative definitions (F11)
Section titled “Declarative definitions (F11)”Per ADR-020, the module ships three sets of declarative definitions registered
by AddGranitDocuments:
- Query definitions —
DocumentQueryDefinition,FolderQueryDefinition,TenantStorageQuotaQueryDefinition(Granit.Documents.{Document|Folder|TenantStorageQuota}Query). - Export definitions —
DocumentExportDefinition,FolderExportDefinition,TenantStorageQuotaExportDefinition(Granit.Documents.{...}Export). - Metric definitions —
DocumentCountMetricDefinition(active documents),FolderCountMetricDefinition(active user-created folders),TotalStorageUsedMetricDefinition(sum ofTenantStorageQuota.UsageBytes).
Metric labels are localized in all 18 framework cultures under the
Metric:Granit.Documents.* keys.
Storage decision matrix
Section titled “Storage decision matrix”When designing a feature on top of Granit.Documents, the module’s three
storage planes serve different roles:
| Plane | What lives here | Lifecycle | When to use |
|---|---|---|---|
BlobDescriptor (Granit.BlobStorage) | The raw bytes + metadata of every uploaded file. | Pending → Valid → Deleted (audit row retained 3 years). | Always — Granit.Documents never stores bytes itself. |
DocumentVersion row | One immutable row per upload, references the BlobDescriptor. | Append-only; never mutated. | When a file should carry version history (the default). |
Document row + CurrentVersionId pointer | The user-facing aggregate (name, description, folder, owner, status). | Active → Trashed → PermanentlyDeleted. | When the file is part of the user’s document tree. |
Files that don’t need version history, ACL inheritance, or trash semantics
should not go through Granit.Documents — Granit.BlobStorage directly is
the right surface (e.g., transient exports, generated PDFs cached on disk,
imaging temp files).
Related
Section titled “Related”Document versions can ship a set of derived files (thumbnail / web / print /
poster) generated by a pluggable provider chain. The derivatives live next to
the original blob, count under a separate RenditionUsageBytes quota counter,
and are exposed through /documents/{id}/renditions. See
Documents — Renditions for the
provider matrix, sandboxing model, and configuration.
Every version also carries searchable descriptive metadata — EXIF for
photos, PDF info dictionary, Office core properties, audio / video tags —
exposed through /documents/{id}/metadata. See
Documents — Asset Metadata for
the extractor chain, the indexed-projection storage model, and the GDPR GPS
scrub on upload.