mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
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:
+1
-1
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user