Skip to content

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.

A DocumentShare is a single ACL grant. Every share is one of:

TargetGranteePermissionInheritance
FolderUser / Role / GroupRead / Edit / ManageInherits to descendants when IsDefault = true
DocumentUser / Role / GroupRead / Edit / ManageNo inheritance (single document)

Two REST endpoints — one nested under each target:

POST /folders/{folderId}/shares
POST /documents/{documentId}/shares

Body:

{
"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.

When the resolver computes the effective permission for a (documentId, principal) pair it considers:

  1. Direct shares on the document.
  2. Shares on the document’s folder.
  3. 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.pdf

A 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.

GrantTargetPermissionVisible on invoice.pdf?
Share on A (IsDefault=true) for User UFolderReadYes — U sees Read
Share on C for Role R (U is in R)FolderEditYes — U sees Edit (highest wins)
Share on B for Group G (U not in G)FolderManageNo — U is not a grantee
Share on sibling /Other for UFolderManageNo — /Other is not an ancestor of C

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 = false and IsDefault = true identically at resolution time. Don’t rely on IsDefault = false to 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.

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.

Coarse permissions complement per-row shares — a caller needs both the framework permission and a matching share to access a document:

PermissionRequired for
Documents.Documents.ReadRead a document the caller has a share on
Documents.Documents.ManageCreate / replace / delete documents
Documents.Folders.ReadList folder contents
Documents.Folders.ManageCreate / move / rename / delete folders
Documents.Shares.ReadList share rows on a folder or document
Documents.Shares.ManageGrant / revoke shares

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.

Documents have a three-state lifecycle: ActiveTrashedPermanentlyDeleted.

  • Trash (IDocumentService.TrashAsync) is reversible. The aggregate row stays; TrashedAt is 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 at Status = PermanentlyDeleted for the GDPR / ISO 27001 audit trail. Every version’s BlobDescriptor is transitioned to BlobStatus.Deleted via IBlobStorage.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.

Granit.Documents.BackgroundJobs ships three recurring jobs on top of Granit.BackgroundJobs:

JobCronPurpose
documents-orphan-cleanup0 * * * * (hourly)Cleans blobs stuck in Pending / Uploading after 24 h via IBlobStorage.CleanupOrphansAsync (F9.1).
documents-empty-trash0 3 * * * (nightly 03:00 UTC)Promotes trashed documents older than TrashRetentionDays to PermanentlyDeleted (F9.2).
documents-quota-recompute0 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.

Granit.Documents.Notifications provides four notification types and two auto-wired handlers on top of Granit.Notifications:

NotificationTriggerHandler?
documents.sharedDocumentShareGrantedEvent (User grantee only)Yes — DocumentSharedHandler
documents.share_revokedDocumentShareRevokedEvent (User grantee only)Yes — DocumentShareRevokedHandler
documents.quota_warninghost-published when usage crosses the early-warning thresholdNo — host publishes
documents.quota_exceededhost-published when an upload is rejected for quotaNo — 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.

Per ADR-020, the module ships three sets of declarative definitions registered by AddGranitDocuments:

  • Query definitionsDocumentQueryDefinition, FolderQueryDefinition, TenantStorageQuotaQueryDefinition (Granit.Documents.{Document|Folder|TenantStorageQuota}Query).
  • Export definitionsDocumentExportDefinition, FolderExportDefinition, TenantStorageQuotaExportDefinition (Granit.Documents.{...}Export).
  • Metric definitionsDocumentCountMetricDefinition (active documents), FolderCountMetricDefinition (active user-created folders), TotalStorageUsedMetricDefinition (sum of TenantStorageQuota.UsageBytes).

Metric labels are localized in all 18 framework cultures under the Metric:Granit.Documents.* keys.

When designing a feature on top of Granit.Documents, the module’s three storage planes serve different roles:

PlaneWhat lives hereLifecycleWhen 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 rowOne immutable row per upload, references the BlobDescriptor.Append-only; never mutated.When a file should carry version history (the default).
Document row + CurrentVersionId pointerThe user-facing aggregate (name, description, folder, owner, status).ActiveTrashedPermanentlyDeleted.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.DocumentsGranit.BlobStorage directly is the right surface (e.g., transient exports, generated PDFs cached on disk, imaging temp files).

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.