tasks: the edge writes identity headers, it does not mint them

Minting is issuing authority. IAM mints — it signs the token, and that
signature is where the authority is created. tasksd verifies one and
restates it as headers a transport that cannot carry a token can read.
Nothing here creates authority, so naming both operations "mint" put the
issuer's job and this service's under one word, and a word that covers
two operations is a word that stops distinguishing them.

Every place that named the header write "mint" now names it "write",
matching the leaf it already cites: hanzoai/authz names its header list
"once by the party that WRITES their values". The four ctx accessors said
"minted" for a value that is neither issued nor a header — they return
what the validated claims RESOLVED to, which is the word the org scoping
already used for Claims.EffectiveOrg.

"mint" stays where it is the right word: the test helper signing a JWT,
the `owner` claim IAM stamps at sign time, the HMAC task tokens dispatch
issues, and money mint/burn in CONTRACT.md.

LLM.md also cited edge.Inject, which no longer exists. The call is
edge.Apply — the name it took when the four readers collapsed onto one
leaf, precisely because writing headers is not injecting authority.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-30 16:02:10 -07:00
co-authored by Hanzo Dev
parent 4cd9de6fed
commit 9259e6f01c
5 changed files with 25 additions and 25 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ Namespaces map 1:1 to IAM orgs. This is non-negotiable.
Workers dial with `Namespace: <orgSlug>`. All schedules, workflows, and
visibility queries are namespace-scoped at the server. An `org-a`
worker cannot see `org-b` workflows even if both share a binary. The
strip + mint identity middleware in `tasksd` enforces this on every RPC.
strip + write identity middleware in `tasksd` enforces this on every RPC.
Multi-tenant services hold one client + worker per active org. Onboarding
a new org allocates the namespace lazily on first `ExecuteWorkflow`.
+4 -4
View File
@@ -315,7 +315,7 @@ further pruning once the native engine settles.
### Auth: IAM only, through the estate's one reader
tasksd validates `Authorization: Bearer <jwt>` itself — no gateway required — and
the strip+mint pattern is the trust boundary. What it does NOT do any more is hold
the strip+write pattern is the trust boundary. What it does NOT do any more is hold
its own reading of that contract.
**The reader is `hanzoai/authz` + `hanzoai/authz/edge`.** `pkg/auth` is down to the
@@ -330,13 +330,13 @@ does not have:
so an unset `TASKSD_JWT_ISSUER` accepted tokens from any issuer instead of failing
closed.
It also stripped only the four names it reads. Every other minted header —
It also stripped only the four names it reads. Every other written header —
`X-User-IsAdmin` and `X-User-Permissions` (the platform and money signals),
`X-User-Owner`, `X-Billing-Account-Id`, `X-Scope`, `X-Workspace-Id`, and the retired
names — passed through from the client untouched, and since tasksd's ingress routes
straight to the Service, nothing upstream was deleting them either. It now strips
`authz.Headers` ∪ `authz.Retired` and mints through `edge.Inject`, so the strip list
IS the mint list.
`authz.Headers` ∪ `authz.Retired` and writes through `edge.Apply`, so the strip list
IS the write list.
And it gains a machine predicate it never had: a `client_credentials` token carries no
membership set, so it is not a person and holds neither admin scope.
+12 -12
View File
@@ -2,7 +2,7 @@
// Package auth — identity-header middleware. The trust boundary is the
// IAM JWT: tasksd validates every Authorization: Bearer <jwt> against
// JWKS, mints X-Org-Id / X-User-Id / X-User-Email from validated claims,
// JWKS, writes X-Org-Id / X-User-Id / X-User-Email from validated claims,
// and unconditionally strips any client-supplied identity headers. There
// is no header-pass-through trust path; client-supplied identity headers
// are never honored.
@@ -22,7 +22,7 @@ import (
)
// The identity header names are the ESTATE's, not this service's: one list, named
// by the party that mints their values (hanzoai/authz), so tasksd cannot come to
// by the party that writes their values (hanzoai/authz), so tasksd cannot come to
// disagree with the edge about what X-Org-Id means.
const (
HeaderOrgID = authz.HeaderOrg
@@ -44,7 +44,7 @@ const (
// RequireIdentity returns middleware that:
// 1. Strips any client-supplied X-Org-Id / X-User-Id / X-User-Email.
// 2. If a Bearer JWT is present, validates it via v and mints fresh
// 2. If a Bearer JWT is present, validates it via v and writes fresh
// identity headers + ctx values from the claims.
// 3. If require=true and no validated identity emerged, returns 401.
//
@@ -56,7 +56,7 @@ func RequireIdentity(v *edge.Verifier, require bool) func(http.Handler) http.Han
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The FULL estate strip, not the four names this service happens to read.
// A header tasksd does not mint but does not delete either is one a client can
// A header tasksd does not write but does not delete either is one a client can
// set and something downstream may believe — X-User-IsAdmin, X-Scope,
// X-Billing-Account-Id and the retired names were all passing straight through.
// The claimed org is discarded: tasksd honours no org switch, so a selection is
@@ -69,8 +69,8 @@ func RequireIdentity(v *edge.Verifier, require bool) func(http.Handler) http.Han
)
if v != nil {
if claims, err := v.Verify(r.Header); err == nil && claims != nil {
// ONE mint, from the one place that decides it — including the two admin
// scopes, which this service never minted at all and which its handlers
// ONE write, from the one place that decides it — including the two admin
// scopes, which this service never wrote at all and which its handlers
// therefore could not have read even where they should.
edge.Apply(r.Header, claims, "", nil)
@@ -100,8 +100,8 @@ func RequireIdentity(v *edge.Verifier, require bool) func(http.Handler) http.Han
// WithIdentity returns a context carrying an ALREADY-VALIDATED identity, for a
// caller that terminates the IAM trust boundary itself and embeds the Tasks HTTP
// surface in-process — e.g. the unified hanzoai/cloud binary, where the gateway
// validates the JWT and mints X-Org-Id / X-User-Id (HIP-0026) before the request
// ever reaches this handler. It is the in-process twin of RequireIdentity's mint
// validates the JWT and writes X-Org-Id / X-User-Id (HIP-0026) before the request
// ever reaches this handler. It is the in-process twin of RequireIdentity's write
// step: the engine reads org/user/email via OrgID/UserID/UserEmail identically,
// whether the identity was validated by the JWT path here or by a trusted
// upstream. Passing empty strings yields the unscoped (dev) context, the same as
@@ -115,18 +115,18 @@ func WithIdentity(ctx context.Context, org, project, user, email string) context
return ctx
}
// OrgID returns the org id minted from a validated JWT, or "".
// OrgID returns the org id resolved from a validated JWT, or "".
func OrgID(ctx context.Context) string { return strFromCtx(ctx, ctxKeyOrgID) }
// ProjectID returns the project id minted from a validated JWT, or "" —
// ProjectID returns the project id resolved from a validated JWT, or "" —
// the org/project/user identity model's middle scope. Convention: a
// project maps onto a tasks NAMESPACE inside the org's shard.
func ProjectID(ctx context.Context) string { return strFromCtx(ctx, ctxKeyProjectID) }
// UserID returns the user id minted from a validated JWT, or "".
// UserID returns the user id resolved from a validated JWT, or "".
func UserID(ctx context.Context) string { return strFromCtx(ctx, ctxKeyUserID) }
// UserEmail returns the user email minted from a validated JWT, or "".
// UserEmail returns the user email resolved from a validated JWT, or "".
func UserEmail(ctx context.Context) string { return strFromCtx(ctx, ctxKeyUserEmail) }
func strFromCtx(ctx context.Context, k ctxKey) string {
+6 -6
View File
@@ -111,7 +111,7 @@ func TestRequireIdentity_StripsClientHeaders_RejectsWithoutToken(t *testing.T) {
}
}
func TestRequireIdentity_ValidJWT_MintsHeaders(t *testing.T) {
func TestRequireIdentity_ValidJWT_WritesHeaders(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
@@ -139,7 +139,7 @@ func TestRequireIdentity_ValidJWT_MintsHeaders(t *testing.T) {
h := RequireIdentity(v, true)(next)
req := httptest.NewRequest(http.MethodGet, "/v1/tasks/foo", nil)
// Spoof headers — must be stripped before mint.
// Spoof headers — must be stripped before the write.
req.Header.Set(HeaderOrgID, "attacker")
req.Header.Set(HeaderProjectID, "attacker-project")
req.Header.Set(HeaderAuthorization, "Bearer "+tok)
@@ -153,7 +153,7 @@ func TestRequireIdentity_ValidJWT_MintsHeaders(t *testing.T) {
t.Fatalf("ctx mismatch: org=%q project=%q user=%q email=%q", gotOrg, gotProject, gotUser, gotEmail)
}
if headerOrg != "hanzo" || headerProject != "acme-app" || headerUser != "user-123" || headerEmail != "[email protected]" {
t.Fatalf("headers not minted from JWT: org=%q project=%q user=%q email=%q", headerOrg, headerProject, headerUser, headerEmail)
t.Fatalf("headers not written from JWT: org=%q project=%q user=%q email=%q", headerOrg, headerProject, headerUser, headerEmail)
}
}
@@ -239,7 +239,7 @@ func TestRequireIdentity_WrongIssuer_Rejects(t *testing.T) {
// tasksd strips the WHOLE estate identity set, not just the four names it reads.
//
// It used to strip exactly X-Org-Id / X-Project-Id / X-User-Id / X-User-Email, so
// every other minted name — X-User-IsAdmin and X-User-Permissions (the platform and
// every other written name — X-User-IsAdmin and X-User-Permissions (the platform and
// money signals), X-User-Owner, X-Billing-Account-Id, X-Scope, X-Workspace-Id, and
// the retired names — passed through from the client untouched. tasksd terminates
// identity itself (its ingress routes straight to the Service), so nothing upstream
@@ -288,7 +288,7 @@ func TestRequireIdentity_StripsTheWholeEstateSet(t *testing.T) {
t.Errorf("retired %s survived as %q", h, seen[h])
}
}
// What the token DID earn is re-minted, so the strip is not just deletion.
// What the token DID earn is re-written, so the strip is not just deletion.
if seen[authz.HeaderOrg] != "hanzo" {
t.Errorf("%s = %q, want hanzo", authz.HeaderOrg, seen[authz.HeaderOrg])
}
@@ -299,7 +299,7 @@ func TestRequireIdentity_StripsTheWholeEstateSet(t *testing.T) {
// An admin-org MACHINE reaches tasksd with no authority. IAM's client_credentials
// grant signs no membership set, which is the machine signal; before this, tasksd
// had no machine predicate at all and would have minted its `owner` as the org while
// had no machine predicate at all and would have written its `owner` as the org while
// letting a forged X-User-IsAdmin ride through untouched.
func TestRequireIdentity_AdminOrgMachineCarriesNoAuthority(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
+2 -2
View File
@@ -235,7 +235,7 @@ func (e *Embedded) Address() string {
// View is an org-scoped, in-process control-plane handle on the embedded
// engine — the door for a HOST binary (e.g. cloud's platform cron) to manage
// namespaces and schedules in a specific org's shard without a ZAP hop or a
// minted identity. In-process callers share the host's trust boundary (the
// written identity. In-process callers share the host's trust boundary (the
// same stance as the ungated loopback listener), so the org they pass is
// authoritative — never derive it from anything client-supplied. Workflows a
// schedule starts dispatch to whatever worker is subscribed on the schedule's
@@ -557,7 +557,7 @@ func (e *Embedded) CancelActivityForOrg(org, ns, activityID, runID, reason, iden
func (e *Embedded) EventsHandler() http.Handler { return e.sseHandler() }
// HTTPHandler returns the browser-only JSON shim. Mirrors zapHandlers.
// Per-request engine is scoped to the X-Org-Id minted by pkg/auth from
// Per-request engine is scoped to the X-Org-Id written by pkg/auth from
// the validated IAM JWT (Authorization: Bearer). Client-supplied
// identity headers are stripped before the handler runs. Empty org →
// legacy unscoped store (embedded/dev path only).