Files: Authorization/SupplyChainAuthorization.cs, Authorization/ClientClaims.cs,
Authorization/Claims/*.cs.
The starter sample’s authorization (Foundgine.SupplyChain/Application/Authorization.cs)
only has one kind of input: an actor + token pair, resolved server-side
into a fixed identity. This sample adds a second, deliberately weaker kind of
input: claims — extra context the caller asserts on top of that
identity (scope=read-only, warehouse=12, reason=..., change_ticket=...).
The single rule that makes this safe, stated in ClientClaimsValidator’s own
doc comment and worth repeating because everything else follows from it:
Identity (tenant, role) is never taken from the caller — it is resolved server-side from the actor/token pair. Claims are additional, caller-asserted context that can only ever narrow what the policy already allows for the authenticated role. Claims are never additive to privilege.
Because an MCP client can send arbitrary JSON, ClientClaimsValidator.Validate
treats every claim as hostile until proven otherwise, in this order:
IdentitySpoofingValidator checks the raw claim keys against
ClaimSchema.ReservedIdentityKeys (role, tenant, actor, isadmin,
permissions, …) before anything else runs. If any reserved key is
present at all — even with a value that happens to match the caller’s
real identity — the entire request is rejected, not just that one
claim. The doc comment on IdentitySpoofingValidator explains why:
partially processing the rest of the call would still leak information
about which other claims would have been honored, and “a client that
tries this once has demonstrated intent that should not be trusted with
partial processing.”
HostileReservedIdentityKeys (a subset: isadmin, permissions,
capabilities, scopes) is classified Hostile; the rest of the
reserved set is Suspicious. Both are rejected identically — the
severity only exists so operators can triage differently, not to
relax the fail-closed behavior.scope, warehouse, max_rows, reason, change_ticket, not_after)
has its own IClaimValidator registered on SupplyChainClaimSchema.Default
— e.g. warehouse must parse as a positive integer, change_ticket must
match ^CHG-\d{4,}$. A malformed value is rejected individually; it
doesn’t fail the whole request, but any privilege that depended on it is
evaluated as if the claim were absent.CrossFieldClaimValidator enforces
that evidence-bearing claims (reason, change_ticket) are only honored
alongside a not_after expiry that (a) hasn’t already passed and (b)
doesn’t exceed ClaimSchema.MaxExpiryHorizon (7 days, in
SupplyChainClaimSchema). The horizon ceiling matters specifically
because without it, a caller could hand-write an expiry decades out and
have the evidence trusted as if it never expired.ClaimSchema object instead of static fieldsClaimSchema bundles the reserved-key set, the hostile subset, the per-key
validators, and the expiry rules into one instance
(SupplyChainClaimSchema.Default) rather than hard-coding them as static
fields on the validator itself. The stated reason (ClaimSchema’s own doc
comment): a different vertical, tenant, or schema version can build its own
instance and reuse IdentitySpoofingValidator / CrossFieldClaimValidator /
ClientClaimsValidator completely unchanged — those three are generic over
any ClaimSchema, and this file is the only place SupplyChain-specific
claim rules live.
SupplyChainAuthorization.Create(tenantId, role, claims) builds a
SemanticAuthorizationConfiguration from five independent rule callbacks.
Each answers a narrower question than “is this allowed,” and Foundgine’s
authorizer combines them — this sample doesn’t reimplement that combination
logic, only supplies the SupplyChain-specific answers:
| Rule | Question it answers | Example from this sample |
|---|---|---|
| Entity rule | Can this role touch this entity at all? | Customer role can never read Supplier, Certification, ComplianceIncident. |
| Field rule | Can this role see/write this specific field? | InventoryLot.Quarantined is readable only by WarehouseOperator/SupplyChainManager; Supplier.RiskScore only by Analyst/SupplyChainManager — see SensitiveFieldAuthorizationTests.cs for the exhaustive matrix. |
| Relationship rule | Can this role traverse this edge? | Supplier.incidents is only traversable by Analyst/SupplyChainManager — a Customer querying Supplier never even sees that edge exists. |
| Predicate rule | What row-level filter applies even when the entity/field/relationship checks pass? | Every Supplier/Warehouse read gets a TenantId == context.TenantId predicate; if the caller’s warehouse claim was accepted, an additional WarehouseId == <claimed warehouse> predicate is ANDed on top. |
| Named-operation rule | Is this specific named write operation (not just “any write”) allowed? | "inventory.reconcile" requires the SupplyChainManager role and both a reason and change_ticket claim to have survived validation — an ordinary "update" on the same entity does not require either. |
AuthorizationPolicyTests.cs exercises all five in one test to show they’re
genuinely independent axes, not layers of the same check — an
Analyst can access ComplianceIncident (entity ✅) but not
InventoryLot.Quarantined (field ❌); a WarehouseOperator can perform
update but not inventory.reconcile on the same entity (named-operation ❌
while the general write rule would say ✅).
Look at GetPredicate: the tenant predicate is always applied for
Supplier/Warehouse reads, and then, only if the caller supplied a valid
warehouse claim, a second predicate is ANDed on top narrowing to that one
warehouse. There’s no path where a claim can replace the tenant predicate
or grant access to a warehouse outside the tenant — claims can only add
ANDed restrictions, never ORed exceptions. That asymmetry is what makes
“claims never add privilege” actually true in the implementation, not just
true in the doc comment.
SupplyChainAuthorization.FieldIds/RelationshipIds resolve ids by
string name at static-initialization time, e.g.:
public static RelationshipId SupplierCertifications =>
SupplyChainSemanticModel.Relationship("Supplier", "certifications");
If you read the walkthrough for the simpler Foundgine.SupplyChain starter,
you’ll recognize this as the exact pattern that starter’s semantic-model
wrapper used to have — and which we replaced there with compiler-checked
constants the AOT generator now emits directly
(GeneratedSemanticModel.<Model>.Relationships.<Name>).
We deliberately did not make that same change here, for a reason this
sample’s own Infrastructure/README.md states explicitly: “there is
deliberately no hand-maintained structural model… replacing the AOT
producer with an EF/database/other metadata producer should not require
changes to semantic configuration.” SupplyChainSemanticModel.Build() also
layers in logical traversals that don’t exist in generated metadata at
all (Product.shipments, Product.supplierIncidents — see
SupplyChainSemanticModel.cs’s Build() method) alongside the structural
relationships. Any single lookup mechanism used everywhere in this file has
to handle both kinds uniformly, and only the runtime, name-based
Model.Get(...).Relationships.Single(...) lookup can — a
compile-time-generated constant only exists for the structural
relationships, not the synthesized traversals.
The typo-safety the compile-time constants would have bought you is instead
covered here by the test suite: SemanticModelTests.cs and
MetadataProducerBoundaryTests.cs resolve every id this file uses, so a
renamed or misspelled relationship fails a fast, deterministic unit test —
not a Single() throw discovered in production. If you’re extending this
project and specifically want compile-time-checked ids for a structural
(non-traversal) relationship, GeneratedSemanticModel.<Model>.Relationships.<Name>
is available after a rebuild — just know that using it here is a deliberate
trade against the “swap metadata producers freely” design goal, not a
strict improvement.