Status: Proposed (spike-backed, not yet scheduled) · Scope: DMS-internal · Author: access-rights brainstorm (DMS-1513 follow-up)
Relation to other work: Generalizes the document access consolidation already underway in COB-1192 Stage 0 (DMS-1513 SS3 built DocumentAccessPolicy + DocumentAccessContext).
This is a design proposal for refinement. As of the latest iteration on
feature/DMS-1513_document-access-policy(uncommitted), the approach is implemented end-to-end for GSI (incl.GsiLog) and Documents, behaviour-preserving (762 unit tests green):GsiActionRightsCheckeris gone,Gsi/GsiLoguseEnsureAccess(AccessSubject, GsiAction), andDocumentAccessContextcarries the sharedAccessSubject. See §8 Inventory.
Document/GSI access rules are spread across four incompatible shapes, and "who can do what" has no canonical representation:
| Shape | Example | Style |
|---|---|---|
| Throw-guards (Domain) | DocumentAccessPolicy.EnsureHblAndBlAccess |
void, throws DmsDomainException |
| Aggregate switch (Domain) | Gsi.CheckAccess(companyId, isOrgAdmin, isSupport, GsiMode) |
void, throws |
| Bool service (Application) | GsiActionRightsChecker.CanApprove(...) (module gate + ownership) |
returns bool |
| Read-side mode (Application) | DocumentQueryService.SetMode → View/Edit/Hidden; GsiQueryService CanApprove flag |
produces flags |
Consequences, all observed in code:
- "May I?" (write) and "What can I do?" (read) are answered by different code that has already drifted — the document read side never inspects
DocumentStatus, so it can reportEditwhile the write path throws on aSurrendered/ReleasedBL. - GSI has two mechanisms that don't know about each other (
Gsi.CheckAccessfor create/edit/remove/apply/reapply,GsiActionRightsCheckerfor approve), and a third divergent twin —GsiLog.CheckAccess'sRemovearm allows owner + collaborators whileGsi.CheckAccess'sRemoveallows only org-admin/support (sameGsiModeenum, different rules). - No enforcement seam — no
[Authorize], no authorization pipeline behavior (onlyTransactionBehaviour). Auth is sprinkled in handlers/aggregates. - Attribute resolution is duplicated —
isShipmentDelegatedToCurrentCompany/isCurrentCompanyDestinationOnlyrecomputed in ~6 handlers; support/CSA identity read inconsistently (ICargooContextvsIHttpContextAccessor).
Cargoo's rules are ABAC (attribute-based) with a ReBAC flavor:
decision = f( subject{company, roles, modules, isSupport, isOrgAdmin, relationship-to-this-resource},
resource{type, status, creator, parties, owner, delegation, destination-only},
action{view/edit/upload/delete/validate/activate/approve/...} )
It is not plain RBAC — a role never decides on its own; it's always "this company, in this relationship to this resource, given this resource's state."
Decision is one thing, consumed two ways (this is what kills the read/write drift):
Evaluate(...) → AccessResult— the brain, never throws. Read paths inspectAllowedto build per-entity capability flags for the UI (BE single source of truth).Ensure...(...)— callsEvaluateand throws on denial. Write paths keep throwing exactly as today.
A MediatR IPipelineBehavior runs before the handler and only has the request — not the loaded Document/Gsi. So authorization splits by what each layer needs:
| Layer | Needs the resource? | Runs in | Examples |
|---|---|---|---|
| Coarse / request-level | No — subject only | AuthorizationBehaviour |
module right (DocumentsSetup: Manage), role / support / org-admin gates |
| Resource-scoped | Yes | handler/aggregate (keeps throwing) | party relationship, document status, GSI owner/collaborator |
The behavior never needs the resource because resource-level enforcement deliberately stays where the resource is loaded — which is where we already throw. GsiActionRightsChecker.CanApprove is the clearest example:
CanApprove = [ COARSE: DocumentsSetup:Manage ] → behaviour / RequireModuleAccess (no resource)
+ [ RESOURCE: owner || orgAdmin || support ] → GsiAccessPolicy.Evaluate(Approve) (needs the gsi)
Domain/Access/
AccessResult.cs (bool Allowed, string DenyReason) + Allow()/Deny()
AccessSubject.cs (long CompanyId, bool IsOrgAdmin, bool IsSupport) — primitives only, no Cargoo.Abstractions
Domain/Models/{Aggregate}/
{Aggregate}Action.cs verbs (GsiAction; a DocumentAction would mirror)
{Aggregate}AccessContext.cs resolved resource attributes (GsiAccessContext, DocumentAccessContext)
{Aggregate}AccessPolicy.cs Evaluate(...) -> AccessResult + Ensure...(...) -> throws
Application/Access/
IAccessRequirement.cs Check(ICargooContext) -> AccessResult (coarse, subject-only)
RequireModuleAccess.cs module-right gate, declared on a command
IRequireAuthorization.cs marks a request carrying coarse requirements
Application/Behaviours/
AuthorizationBehaviour.cs evaluates coarse requirements before the handler; throws DmsDomainException
Layer placement (consistent with COB-1192 D2): Domain policies (pure rules) fed by an Application-resolved subject + context. Module/role gates are Application concerns (Cargoo.Abstractions), so they live in the coarse layer — keeping the Domain dependency-free.
Both already do, in the spike:
- GSI (implemented) —
Gsi.CheckAccess→EnsureAccess(AccessSubject, GsiAction)delegating toGsiAccessPolicy;GsiLoglikewise via a separateGsiLogAccessPolicythat preserves its divergentRemoverule.GsiActionRightsCheckeris deleted: theApprovewrite-path enforces the coarse module gate viaAuthorizationBehaviour(RequireModuleAccessonApproveGsiCommand) + the resource rule viaGsiAccessPolicy.Ensure; the read flag (GsiQueryService/GsiLogQueryServiceCanApprove) isGsiApprovalRights.CanApprove, composing the sameRequireModuleAccess+GsiAccessPolicy. Read and write can no longer drift. - Documents (implemented) —
DocumentAccessPolicyhasEvaluateHblAndBlAccess(no throw) +EnsureHblAndBlAccess(delegates + throws), andDocumentAccessContextnow carries the sharedAccessSubject(identity), keepingIsCsaCreatorresource-side. Documents gate onSubject.CompanyId/Subject.IsSupport;Subject.IsOrgAdminis unused by document rules (it's a GSI concern) — the price of one shared subject type. - Other entities (future) — same pattern incrementally: DocumentFile validation (
CheckValidationAccess, already in the document policy); HouseShipment access is currently a service existence/ownership check (CheckHouseShipmentExistance), not a multi-rule policy, so it doesn't fit this shape as-is.
Each step is an extraction with the call sites unchanged and existing tests green; no big-bang.
- Foundation — land
AccessSubject/AccessResult, anISubjectAccessor(unifyICargooContext+ theIsSupportGroupMemberread fromIHttpContextAccessor), andAuthorizationBehaviour+IAccessRequirement/RequireModuleAccess(no-op until a command opts in). - GSI —
GsiAccessPolicy; shimGsi.CheckAccess; foldGsiActionRightsChecker; decide theGsiLog.Removedivergence (preserve, or unify — a product call); move the module gate fully into the behaviour and remove it fromCanApprove. Pin withGsiTests/GsiLogTests/ handler + query tests. - Documents — already the COB-1192 track:
Evaluate/Ensure(done in spike),DocumentAccessContextFactory(SS4), capability flags wired intoDocumentDto/DocumentFileDto(SS5). Converging read vs write rules (so a card never renders editable then throws) is a deliberate behaviour change (COB-1192 D3) — schedule it, don't unify silently. - Other entities — HouseShipment, downloads, validation — one at a time, same shape.
- Keep throwing (decided) — the aggregate stays the enforcement point; the policy is the shared brain. Compatible:
Ensurethrows,Evaluatedoesn't. - Read/write convergence is a behaviour change — both the document status drift and the
GsiLog.Removedivergence must be decided deliberately, not unified by accident. - Transitional double-enforcement — while a coarse gate lives both in a checker and on a command, it's enforced twice (harmless, same result). Productizing means picking one home (the behaviour).
- No external library (decided) — Cerbos/OpenFGA/Casbin add a runtime + a separate rule language but still need us to resolve "is company X the OHA of shipment Y" in C#. Revisit only if authz must be shared/audited across multiple Cargoo services.
- Identity unification — wrapping
IsSupportGroupMember(currentlyIHttpContextAccessor) inside the subject is COB-1192 L1/A1.
Branch feature/DMS-1513_document-access-policy (uncommitted): new Domain/Access/, Domain/Models/Gsi/{GsiAction,GsiAccessContext,GsiAccessPolicy}, Application/Access/, Application/Behaviours/AuthorizationBehaviour; shims in Gsi.cs + GsiActionRightsChecker.cs; Evaluate/Ensure split in DocumentAccessPolicy.cs; demo marker on ApproveGsiCommand + registration in ServicesStartupExtension. Tests: GsiAccessPolicyTests (17), AuthorizationBehaviourTests (2), DocumentAccessPolicyTests (+2 read/write-agreement). All existing tests stayed green — proof the shims are behaviour-preserving.