ADR-060: OIDC sub-as-Guid policy for domain ownership
Date: 2026-05-26 Authors: Jean-Francois Meyers Scope:
granit-dotnet—Granit.Users.ICurrentUserService, every domain module that types ownership asGuid(starting withGranit.Documents).
Context
Section titled “Context”ICurrentUserService.UserId is string? by design — it carries the raw OIDC
sub claim and makes no assumption about its shape. That choice is correct
for the audit plane: CreatedBy / ModifiedBy columns are string precisely
so they survive any provider’s identifier format.
Domain modules tell a different story. When a module needs to express
“this entity is owned by user X” as a first-class concept (ADR-052 —
Documents module is the
first; more will follow), OwnerId is a typed Guid column. Two
arguments push that direction:
- Storage symmetry. Every other user-shaped FK in the framework
(
UserCacheEntry.Id,GranitUser.Id,Party.UserId) is already aGuid. KeepingOwnerIdasstringwould force a parse at every join. - Index discipline. IOwnable declares
Guid OwnerId { get; }precisely soApplyGranitConventionscan emit a(TenantId, OwnerId)composite index. A nullable-string ownership key would defeat the index.
The friction is the sub claim itself. Across the four providers Granit
supports, sub is not always a Guid.
Decision
Section titled “Decision”Add a best-effort default-impl UserGuid to ICurrentUserService:
Guid? UserGuid => Guid.TryParse(UserId, out Guid id) ? id : null;Three properties of this shape:
- Default-impl, not abstract. Existing
ICurrentUserServiceimplementations (5 in the framework, more in consuming apps) keep compiling unchanged. The member surfaces on the interface, but no impl needs to override it unless it has a faster path. Guid.TryParse, notGuid.Parse. A non-Guidsubreturnsnull— it never throws insideICurrentUserService. The decision of what to do with a null typed identity is pushed up to the caller, where it has context (which command? which aggregate? which fallback?).- No silent
Guid.Empty. ReturningGuid.Emptyon parse failure would propagate a bogus FK into persistence and corrupt the (TenantId,OwnerId) index. The contract is nullable, and callers that require an owner identity throw at the boundary.
IDP compatibility matrix
Section titled “IDP compatibility matrix”| Provider | sub shape | UserGuid value | Notes |
|---|---|---|---|
| Keycloak | UUID v4 (a1b2c3d4-...-...) | Guid | Native — every Keycloak user has a UUID sub. |
| Entra ID | UUID v4 (object id) | Guid | Native — oid and sub are both Guids for the application user. |
| AWS Cognito (user pool only) | UUID v4 | Guid | Native — pool-issued sub is a Guid. |
| AWS Cognito (federated identities) | <region>:<guid> (e.g. eu-west-1:a1b2...) | null | Pool prefix breaks Guid.TryParse. Apps that need ownership in this mode must override UserGuid and strip the prefix, or use Cognito user pool mode. |
| Google Cloud Identity | numeric string (109876543210...) | null | Google issues numeric sub. Ownership-typed domain modules are not supported on Google IDP without a custom UserGuid resolver mapping numeric sub → local user aggregate. |
The matrix is checked in Granit.Authentication.JwtBearer.* integration tests.
When a new provider integration ships, it must declare whether sub is
natively Guid-parseable; if not, it must publish a documented UserGuid
override (or accept that ownership-typed modules are not usable on that IDP).
Caller contract
Section titled “Caller contract”Modules consuming UserGuid MUST handle null explicitly at the
boundary — typically a command handler or aggregate factory — and
SHOULD throw rather than fall back to Guid.Empty:
public async Task<Document> CreateAsync(string name, CancellationToken ct){ Guid ownerId = currentUser.UserGuid ?? throw new InvalidOperationException( "Document ownership requires a Guid-parseable user identity. " + $"Current sub '{currentUser.UserId}' is not a Guid. " + "Check IDP configuration or override ICurrentUserService.UserGuid.");
Document doc = Document.Create(name, ownerId); // ...}This places the failure where it is actionable (a 500 with a clear message on the create endpoint) rather than where it manifests downstream (a foreign-key violation on the next save, or a silently empty owner column).
Two consumers are wired this way in the framework today:
Granit.Documents—Document.Create/Folder.Createrequire a non-emptyGuid ownerIdparameter; the endpoint layer resolves it viaUserGuid ?? throw.Granit.Authorization— OwnedByCurrentUserHandler — the resource-based auth handler is fail-silent: whenUserGuid is null, the requirement remains unsatisfied (the handler does not callcontext.Fail()), so a stacked permission-based or role-based policy can still grant access. This is the inverse of the command-side decision: at the auth layer, missing-Guid is a non-grant, not an error.
Alternatives considered
Section titled “Alternatives considered”A. Make UserGuid an abstract member of ICurrentUserService
Section titled “A. Make UserGuid an abstract member of ICurrentUserService”Rejected.
- Breaking change for every consumer. 5 framework impls plus an unknown count in business apps would all need to implement it. The benefit (provider picks the optimal path) does not outweigh the migration cost.
- The default impl is the right answer for 3 of 4 providers. Only Cognito federated identities and Google need a custom path; the rest get the correct value for free.
B. Throw inside UserGuid on non-Guid sub
Section titled “B. Throw inside UserGuid on non-Guid sub”Rejected.
- Wrong layer.
ICurrentUserServiceis a contextual accessor — it must not throw for a state the IDP legitimately produces. Authentication succeeded; the actor is just not a Guid. Throwing here turns every audited write into a 500 on Google IDP, even for code paths that don’t need typed ownership. - Loses information. A throw collapses two failure modes (“no user at
all” vs “user but non-Guid
sub”) into one. The nullable return preserves both.
C. Silently coerce to Guid.Empty
Section titled “C. Silently coerce to Guid.Empty”Rejected. This is the failure mode the ADR is designed to prevent — see the “Caller contract” section.
D. Store ownership as string and skip the typed Guid plane
Section titled “D. Store ownership as string and skip the typed Guid plane”Rejected.
- Defeats the index convention. IOwnable’s value
is the automatic
(TenantId, OwnerId)index. A string FK would still index, but joins againstUserCacheEntry.Id/GranitUser.Id(both Guid) would require a per-row parse — measurably worse on tenants with millions of rows. - Mismatch with DDD aggregate identity. Every user-shaped aggregate in
Granit uses
Guidas its identifier. A string ownership column is an outlier in the domain model.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Existing code unchanged. Default-impl means no breaking change. The 5
framework
ICurrentUserServiceimplementations and every consumer in business apps keep compiling. - Typed ownership is now expressible. IOwnable
has a clean primitive to consume —
Guid?with a documented null contract, not an opportunisticGuid.Parsescattered across modules. - Cognito-federated and Google failures surface early. On those IDPs,
UserGuidisnullfrom the first authenticated request. The first attempt to create an ownership-typed entity throws with a clear message, not a silent corruption.
Negative
Section titled “Negative”- Ownership-typed modules are IDP-conditional. Apps deployed against
Google IDP or Cognito federated identities cannot use
Granit.Documents(or any futureIOwnablemodule) without supplying a customUserGuidresolver. This is documented in the matrix above and surfaced by the throw at create time.
Neutral
Section titled “Neutral”- Audit columns stay
string.CreatedBy/ModifiedBystill consumeUserId(the rawsub), unaffected by this ADR. The two planes — audit identity (string) and domain ownership (Guid) — are deliberately distinct.
References
Section titled “References”- IOwnable — the first consumer of
UserGuid - Security Overview —
ICurrentUserServicesurface - Authentication — IDP integrations and provider-specific claims
- ADR-052 — Documents module, the first domain module to adopt the typed-ownership pattern
- PR #2298 —
UserGuiddefault-impl - PR #2300 —
IOwnable+OwnedRequirement+OwnedByCurrentUserHandler