Skip to content

Instantly share code, notes, and snippets.

@boicualexandru
Last active June 26, 2026 08:26
Show Gist options
  • Select an option

  • Save boicualexandru/1fa3f9705db066b91aa649489e64000c to your computer and use it in GitHub Desktop.

Select an option

Save boicualexandru/1fa3f9705db066b91aa649489e64000c to your computer and use it in GitHub Desktop.

Unified Access Authorization for DMS

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): GsiActionRightsChecker is gone, Gsi/GsiLog use EnsureAccess(AccessSubject, GsiAction), and DocumentAccessContext carries the shared AccessSubject. See §8 Inventory.


1. Why

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.SetModeView/Edit/Hidden; GsiQueryService CanApprove flag produces flags

Consequences, all observed in code:

  1. "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 report Edit while the write path throws on a Surrendered/Released BL.
  2. GSI has two mechanisms that don't know about each other (Gsi.CheckAccess for create/edit/remove/apply/reapply, GsiActionRightsChecker for approve), and a third divergent twinGsiLog.CheckAccess's Remove arm allows owner + collaborators while Gsi.CheckAccess's Remove allows only org-admin/support (same GsiMode enum, different rules).
  3. No enforcement seam — no [Authorize], no authorization pipeline behavior (only TransactionBehaviour). Auth is sprinkled in handlers/aggregates.
  4. Attribute resolution is duplicatedisShipmentDelegatedToCurrentCompany / isCurrentCompanyDestinationOnly recomputed in ~6 handlers; support/CSA identity read inconsistently (ICargooContext vs IHttpContextAccessor).

2. The model

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 inspect Allowed to build per-entity capability flags for the UI (BE single source of truth).
  • Ensure...(...) — calls Evaluate and throws on denial. Write paths keep throwing exactly as today.

3. Coarse vs resource-scoped (why the pipeline behavior never needs the loaded resource)

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)

4. Building blocks (reference implementation = the spike)

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.

5. How each aggregate adheres

Both already do, in the spike:

  • GSI (implemented)Gsi.CheckAccessEnsureAccess(AccessSubject, GsiAction) delegating to GsiAccessPolicy; GsiLog likewise via a separate GsiLogAccessPolicy that preserves its divergent Remove rule. GsiActionRightsChecker is deleted: the Approve write-path enforces the coarse module gate via AuthorizationBehaviour (RequireModuleAccess on ApproveGsiCommand) + the resource rule via GsiAccessPolicy.Ensure; the read flag (GsiQueryService/GsiLogQueryService CanApprove) is GsiApprovalRights.CanApprove, composing the same RequireModuleAccess + GsiAccessPolicy. Read and write can no longer drift.
  • Documents (implemented)DocumentAccessPolicy has EvaluateHblAndBlAccess (no throw) + EnsureHblAndBlAccess (delegates + throws), and DocumentAccessContext now carries the shared AccessSubject (identity), keeping IsCsaCreator resource-side. Documents gate on Subject.CompanyId/Subject.IsSupport; Subject.IsOrgAdmin is 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.

6. Migration plan (strangler, behaviour-preserving, test-pinned)

Each step is an extraction with the call sites unchanged and existing tests green; no big-bang.

  1. Foundation — land AccessSubject/AccessResult, an ISubjectAccessor (unify ICargooContext + the IsSupportGroupMember read from IHttpContextAccessor), and AuthorizationBehaviour + IAccessRequirement/RequireModuleAccess (no-op until a command opts in).
  2. GSIGsiAccessPolicy; shim Gsi.CheckAccess; fold GsiActionRightsChecker; decide the GsiLog.Remove divergence (preserve, or unify — a product call); move the module gate fully into the behaviour and remove it from CanApprove. Pin with GsiTests / GsiLogTests / handler + query tests.
  3. Documents — already the COB-1192 track: Evaluate/Ensure (done in spike), DocumentAccessContextFactory (SS4), capability flags wired into DocumentDto/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.
  4. Other entities — HouseShipment, downloads, validation — one at a time, same shape.

7. Risks & decisions

  • Keep throwing (decided) — the aggregate stays the enforcement point; the policy is the shared brain. Compatible: Ensure throws, Evaluate doesn't.
  • Read/write convergence is a behaviour change — both the document status drift and the GsiLog.Remove divergence 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 (currently IHttpContextAccessor) inside the subject is COB-1192 L1/A1.

8. Spike inventory

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment