Skip to content

AccountId must be a non-nullable Guid: the write guard and the #562 token walk are both fail-open for any other shape #673

Closed

Description

@mforce

Found by the tenant-isolation adversary seat reviewing #562 / PR #671 (2026-09-02). Latent — no such property exists today — but both layers are fail-OPEN for it, and the guard that should catch it mirrors the same predicate.

The shape

Both write-side layers key on AccountId being a non-nullable Guid:

So a future tenant-owned entity mapped with public Guid? AccountId (or a strongly-typed id with a converter) reopens exactly the #562 shape: a detached Update(stub)/Remove(stub) with AccountId = null emits UPDATE … WHERE "Id" = @id alone and the database relabels or deletes another farm's row — with every test green.

PR #671's fix round tightened AccountIdConcurrencyTokenModelTests so that the guard now selects carriers by name and non-key only and asserts ClrType == typeof(Guid) — a nullable AccountId reddens the model test instead of vanishing from it. That is a guard, not a mechanism.

Close it by construction

  1. Make the walk fail closed: an AccountId property whose CLR type is not exactly Guid throws at model build (InvalidOperationException naming the entity), so a mis-typed AccountId fails every boot and every test rather than passing unguarded.
  2. Make the interceptor fail closed on the same condition: a non-Guid AccountId value on an Added/Modified/Deleted entry under a resolved tenant throws TenantWriteMismatchException (or a dedicated exception) rather than returning.

Verify

A model-only test that maps a throwaway entity with Guid? AccountId into a ModelBuilder and asserts the walk throws; a mutation that deletes the throw must redden it. For the interceptor, a stub whose AccountId property is boxed as null on a Modified entry must be refused.

Activity

  1. mforce commented on Sep 3, 2026

    @mforce
    OwnerAuthor

    Sharper detail from the round-2 adversary seat on PR #671 (2026-09-02), recorded so the by-construction fix covers it:

    • A populated Guid? boxes to a plain Guid, so TenantStampInterceptor's value is not Guid checks still fire for it — the interceptor is fail-open for that shape only when the value is null. The model walk, by contrast, skips the whole property (ClrType is Guid?, not Guid), so the token layer is fail-open for the type unconditionally.
    • The null case on Added is the interesting one: StampOrVerifyAdded returns early on a non-Guid value without stamping, so a row would be inserted with AccountId = NULL — invisible to every query filter (e.AccountId == tenant.AccountId is NULL for every tenant) and unreachable through the app afterwards. A fail-closed interceptor should refuse that insert, not skip it.

    PR #671's model guard names any non-Guid AccountId (AccountIdConcurrencyTokenModelTests), and its message was corrected in the same PR to say exactly the above rather than "fail-open in both".

  2. mforce commented on Sep 3, 2026

    @mforce
    OwnerAuthor

    One more wording note from the round-3 adversary seat on PR #671, parked here because #673 is where converted-type AccountIds get handled: the model guard's assertion message says the write guard "skips a null value" — it skips any value that does not box to a Guid, which includes a converter-backed / strongly-typed id that is non-null. The comment above the selector already states the general form; only the message's parenthetical narrows it. One-word fix ("a value that is not a Guid") whenever the file is next touched.

  3. added a commit that references this issue on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layer

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions