CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
auth-contracts is the frozen shape of the identity token every Go service trusts, plus the one role-weight comparison every domain-scoped check reduces to. It has zero external dependencies — that’s deliberate. Its whole reason to exist is to be imported by code that does very different jobs — claims-gateway, which signs the token; auth-middleware, which verifies it; and auth-policy and tenancy, which make authorization decisions from it — and none of those imports should have to drag in anything beyond this one small, stable shape.
graph TD
AC["auth-contracts\nStandardClaims · Membership · RoleWeights · Can()"]
SIGN["claims-gateway/internal/signer\nmints the token"]
MW["auth-middleware\nverifies the token"]
AP["auth-policy\npre-expands memberships for Cedar"]
TS["tenancy\nclaims.CanIn(domain, role)"]
RS["rust: digital-twin\nincludes roles.json at compile time"]
RS2["rust: telemetry\nincludes roles.json at compile time"]
PY["python: agent\nreads roles.json at import time"]
SIGN --> AC
MW --> AC
AP --> AC
TS --> AC
AC -.->|"roles.json, read directly\n(no Go dependency)"| RS
AC -.->|"roles.json, read directly\n(no Go dependency)"| RS2
AC -.->|"roles.json, read directly\n(no Go dependency)"| PYclaims-gateway’s signer produces a StandardClaims value; auth-middleware, auth-policy, and tenancy consume it. Three non-Go services — digital-twin and telemetry (Rust) and agent (Python) — read roles.json directly off disk rather than depending on this package, since there’s no Go module boundary to cross for a static JSON file (see Auth Policy for how that split plays out for the Cedar bundle too).
StandardClaims — the one shape every service verifiesFile: claims.go
// Membership is one (domain, role) pair. ExternalId carries the source IdP's identifier
// for the membership, kept for audit and for unlinking a federated identity.
type Membership struct {
Domain string `json:"domain"`
Role string `json:"role"`
ExternalID string `json:"externalId,omitempty"`
}
// StandardClaims is the one token shape every service verifies, regardless of
// tier or which IdP authenticated the user.
type StandardClaims struct {
Sub string `json:"sub"`
AccountName string `json:"accountName"`
SID string `json:"sid"`
Memberships []Membership `json:"memberships"`
}The struct carries no tier- or provider-specific fields. Whether an account signed up directly or federated in through an enterprise identity provider, and whether it holds one membership or twenty, it arrives at every downstream consumer in exactly this shape — none of them need to special-case a federation path.
Three methods answer the questions a consumer actually asks, rather than making every caller walk Memberships by hand:
// Returns the caller's role within domain, if they belong to it.
func (c StandardClaims) RoleIn(domain string) (string, bool) {
for _, m := range c.Memberships {
if m.Domain == domain {
return m.Role, true
}
}
return "", false
}
// Authorization decision scoped to one tenant.
func (c StandardClaims) CanIn(domain, required string) bool {
role, ok := c.RoleIn(domain)
return ok && Can(role, required)
}
// Tenants lists every domain the caller belongs to, in membership order —
// used to scope bulk/`$in`-style queries and to populate a client's
// active-tenant switcher.
func (c StandardClaims) Tenants() []string {
tenants := make([]string, len(c.Memberships))
for i, m := range c.Memberships {
tenants[i] = m.Domain
}
return tenants
}CanIn is what tenancy calls directly for its own domain-scoped checks — it doesn’t need Cedar’s generality for a plain “is this role at least this powerful, in this domain” question. The same question asked with admin-bypass or resource-type nuance goes through Auth Policy instead.
A real test makes the domain-scoping explicit rather than leaving it implied:
func TestCanIn_ScopesTheCheckToTheDomain(t *testing.T) {
c := claims() // business_admin in "acme", standard_customer in "unibo"
if !c.CanIn("acme", "business_admin") {
t.Fatal("business_admin in acme should pass a business_admin check")
}
// admin in acme, but only standard_customer in unibo — the same person,
// different tenants, different outcome. This is the whole point of
// per-membership roles rather than a role on the user.
if c.CanIn("unibo", "business_admin") {
t.Fatal("standard_customer in unibo must not pass a business_admin check")
}
}roles.json and Can()File: roles.go
The ladder itself is data, not code, embedded into the binary at compile time:
//go:embed roles.json
var rolesRaw []byte
// RoleWeights is the single role ladder shared by every service.
var RoleWeights = mustLoadRoleWeights()
func Can(principalRole, requiredRole string) bool {
have, ok := RoleWeights[principalRole]
if !ok {
return false
}
need, ok := RoleWeights[requiredRole]
if !ok {
return false
}
return have >= need
}{
"admin": 100,
"business_admin": 80,
"business_staff": 60,
"standard_customer": 10
}Can compares weights, not role names — an unrecognised role on either side (a typo, a role from a future migration nobody’s deployed yet) fails the lookup and denies, rather than panicking or silently passing. This is the same “at least this powerful” comparison the domain model commits to in Identity & Access Context — roles.json is where that abstract ranking becomes the concrete numbers every language’s implementation reads.
//go:embed means the ladder ships inside the compiled binary — there’s no runtime file read, and no way for a service to start with a roles.json that doesn’t match what it was built against.
Files: conformance_test.go, ../schemas/fixtures/standard-claims.json
// TestFixtureConforms asserts the shared fixture unmarshals cleanly
// into StandardClaims with every required field populated. This is the drift
// detector: any consumer (Go today, others later) asserts against the same
// fixture file, so a shape change that breaks a consumer fails here first,
// not in a production cookie.
func TestFixtureConforms(t *testing.T) {
raw, _ := os.ReadFile("../schemas/fixtures/standard-claims.json")
var claims StandardClaims
if err := json.Unmarshal(raw, &claims); err != nil {
t.Fatalf("fixture does not conform to StandardClaims: %v", err)
}
// ...checks every required field is populated, and that each
// membership's role is a known entry in RoleWeights.
}The fixture lives in schemas/fixtures/, outside this module, because it is no longer only Go’s: Claims Schema (Rust) and agent (Python) assert the same file from their own decoders. A StandardClaims field rename or removal fails in all three languages at once, before it reaches auth-middleware’s decoder or claims-gateway’s signer.
X-Signature — the internal service-to-service credentialFile: internalsig.go
Control-plane calls carry no end user, so there is no x-gateway-claims to forward — provisioner reconciling a pending organization is not acting for anybody. Those hops authenticate the caller itself with an HMAC over the exact request body:
const SignatureHeader = "X-Signature"
// Sign is the internal service-to-service convention: lowercase hex of
// HMAC-SHA256 over the exact request body, empty body included.
func Sign(secret, body []byte) string {
mac := hmac.New(sha256.New, secret)
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
func Verify(secret, body []byte, signature string) bool {
return hmac.Equal([]byte(signature), []byte(Sign(secret, body)))
}RequireSignature(secret) is the verifying middleware. It reads the body once and puts it back on the request, so handlers behind it still see their payload:
body, err := io.ReadAll(r.Body)
// ...
r.Body = io.NopCloser(bytes.NewReader(body))
if !Verify(secret, body, signature) {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}Six implementations of this one convention used to exist — two byte-identical verifying middlewares (registry, tenancy), three inline signers (provisioner × 2, claims-gateway), and telemetry’s Rust one. The five Go copies are now this package:
| Caller | Callee | Signs with |
|---|---|---|
provisioner | registry /internal/organizations/* | authcontracts.Sign |
provisioner | tenancy /internal/domains | authcontracts.Sign |
claims-gateway | tenancy /internal/memberships, /internal/provision | authcontracts.Sign |
registry and provisioner need the convention but not JWT verification. Putting it in auth-middleware would drag jwt, keyfunc and jwkset into two modules that never verify a token; this package stays stdlib-only, so they gain one local replace and nothing else.
telemetry’s ingest verifier stays a separate Rust implementation — it guards a device-facing endpoint with its own key (TELEMETRY_INGEST_SECRET), and a Go package cannot be imported into it anyway. What keeps the two honest is schemas/fixtures/internal-signature.json: golden (secret, body, signature) vectors that internalsig_test.go and telemetry’s ingest_auth.rs both assert. A change to the algorithm on either side fails on both.