iam: a refused key says WHICH refusal it was

"the entity does not exist" was one sentence for causes that call for
opposite actions. A holder whose key was revoked went looking for a
deleted org instead of minting a new key, and a tenant admin's forgery
attempt — the same-tenant pin firing — was indistinguishable from a typo.

The reason is now a value (store.KeyFailure) carried beside the error
rather than baked into its text. KeyError unwraps to orm.ErrNotFound, so
every existing errors.Is caller keeps working unchanged and unaware.

Both doors enumerate honestly: the secret door distinguishes unknown /
wrong-door / foreign-user / dangling-user, and the publishable door
distinguishes unknown / not-publishable / expired — the trio cloud's own
test annotated while having no way to tell them apart.

The human `msg` is byte-identical; the reason rides as `code`. Nothing
that reads the prose can tell the causes apart, and the caller that
reaches it has already passed CapKeyResolve — it can resolve any key to
a full principal, so the code discloses nothing it could not obtain.

A store fault yields no reason at all, so infrastructure trouble is never
reported to a holder as a bad credential.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zooqueen
2026-08-01 13:48:42 -07:00
co-authored by hanzo-dev
parent 7af0326e2d
commit 99e8bdfeb8
6 changed files with 285 additions and 30 deletions
+13 -5
View File
@@ -281,10 +281,18 @@ type keyUser struct {
// its owning principal. The gate is service-only and fail-secure: the caller must be
// a confidential app (p.App != "") holding CapKeyResolve — a human, even a
// SuperAdmin, is refused, because a capability is held vacuously by non-apps and key
// resolution is a machine-identity boundary, never an interactive admin action. An
// unknown/unresolvable key answers the same not-exist envelope every other get- verb
// uses, so a prober cannot distinguish a missing key from a denied one beyond the
// auth refusal.
// resolution is a machine-identity boundary, never an interactive admin action.
//
// THE `msg` STAYS UNIFORM AND THE `code` SAYS WHY. Every unresolvable key answers the
// same not-exist sentence every other get- verb uses, so nothing that reads the prose
// can tell a missing key from a denied one. The machine-readable reason rides beside
// it because THE GATE ABOVE IS THE BOUNDARY, not the vagueness of this sentence: a
// caller that reaches this line has already proven it is a confidential app holding
// CapKeyResolve, and such a caller can resolve any key it likes to a full principal.
// Telling it which refusal occurred discloses nothing it could not already obtain,
// and withholding it is what made a revoked key indistinguishable from a deleted org
// for every human downstream. There is no anonymous reader of this envelope to
// oracle.
func resolveUserByAccessKey(c *zip.Ctx, db orm.DB, key string) error {
ctx := c.Context()
p, ok := authz.From(ctx)
@@ -293,7 +301,7 @@ func resolveUserByAccessKey(c *zip.Ctx, db orm.DB, key string) error {
}
u, err := store.UserByAccessKey(ctx, db, key)
if errors.Is(err, orm.ErrNotFound) {
return httpx.Err(c, "the entity does not exist")
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
}
if err != nil {
return httpx.Err(c, err.Error())
+50
View File
@@ -40,6 +40,7 @@ const (
type keyEnv struct {
Status string `json:"status"`
Msg string `json:"msg"`
Code string `json:"code"`
Data struct {
Owner string `json:"owner"`
Name string `json:"name"`
@@ -231,3 +232,52 @@ func TestGetUserByAccessKey_EmptyFallsThrough(t *testing.T) {
t.Fatalf("empty accessKey did not fall through to owner/name read: status=%d body=%s", status, body)
}
}
// The refusal REASON reaches the wire while the human sentence stays uniform.
//
// "the entity does not exist" is IAM's generic answer, and cloud rendered it verbatim
// to users: a holder whose key had been revoked was told their entity was gone and
// went looking for a deleted organization instead of minting a new key. The prose is
// deliberately unchanged — nothing that reads `msg` can tell the causes apart — and
// the machine-readable `code` carries the reason to the confidential app that already
// passed CapKeyResolve to get here.
func TestGetUserByAccessKey_RefusalCarriesItsReason(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
for _, tc := range []struct{ name, key, wantCode string }{
{"revoked / never minted", "hk-live-NOSUCHKEY", "key_unknown"},
{"unknown secret half", "sk-live-NOSUCHKEY", "key_unknown"},
{"a publishable key at the SECRET door", projPK, "key_wrong_door"},
{"an unrecognized shape", "fw_deadbeef", "key_wrong_door"},
} {
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Msg != "the entity does not exist" {
t.Fatalf("%s: env=%+v — the human sentence must stay uniform", tc.name, e)
}
if e.Code != tc.wantCode {
t.Errorf("%s: code = %q, want %q", tc.name, e.Code, tc.wantCode)
}
// The credential must never be echoed back, in any field.
if strings.Contains(body, tc.key) {
t.Errorf("%s: the refusal echoed the presented key: %s", tc.name, body)
}
}
}
// The AUTH refusal is not a key reason. A caller that fails the CapKeyResolve gate
// gets the unauthorized envelope and NO code at all — so a non-cap caller can never
// use `code` as an existence oracle for keys it may not resolve.
func TestGetUserByAccessKey_NonCapCallerLearnsNoReason(t *testing.T) {
h := newHarness(t)
keyFixtures(t, h)
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+keyUserHK, otherApp, svcSecret)
var e keyEnv
_ = json.Unmarshal([]byte(body), &e)
if e.Status != "error" || e.Code != "" {
t.Fatalf("non-cap caller env=%+v, want an error with NO code", e)
}
}
+10 -4
View File
@@ -36,8 +36,11 @@ type resolveResponse struct {
// It names an organization and never a person: no path through it can load or
// return a user, so a key you put in client code cannot become a way to learn
// who anyone is. A key that is expired, secret rather than publishable, or
// simply unknown all answer identically, so nothing here can be probed to
// discover which keys exist.
// simply unknown all answer with the same sentence, and with a `code` saying
// which of those it was. Only a confidential service that already proved it may
// resolve keys at all ever reads that code — there is no anonymous caller here
// to probe for which keys exist — and telling it apart is what lets the holder
// be told to re-mint an expired key instead of hunting a configuration error.
func resolveKeyHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
@@ -48,8 +51,11 @@ func resolveKeyHandler(db orm.DB) zip.Handler {
k, err := store.PublishableKeyByAccessKey(ctx, db, c.Query("accessKey"), time.Now())
if err != nil {
// Not found, not a pk-, not publishable, expired, or a store error — one
// opaque envelope, no oracle (store.PublishableKeyByAccessKey fails closed).
return httpx.Err(c, "the entity does not exist")
// envelope, and `code` distinguishes them for the confidential app that
// already passed CapPublishableResolve above. A store fault yields no
// reason at all (store.Reason returns ""), so infrastructure trouble is
// never reported to the holder as a bad key.
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
}
return httpx.Ok(c, resolveResponse{Org: k.Owner, Scope: k.Scope})
}
+19 -6
View File
@@ -22,11 +22,18 @@ import (
type Response struct {
Status string `json:"status"`
Msg string `json:"msg"`
Sub string `json:"sub,omitempty"`
Name string `json:"name,omitempty"`
Data any `json:"data"`
Data2 any `json:"data2,omitempty"`
Data3 any `json:"data3,omitempty"`
// Code is a STABLE machine-readable reason, where the human `msg` is
// deliberately generic. `msg` is prose for a person and several distinct causes
// legitimately share one sentence; a caller that must BRANCH on the cause — or
// tell its own user which of them happened — cannot parse prose. Optional, so
// every existing envelope is byte-identical and no SDK changes.
Code string `json:"code,omitempty"`
Sub string `json:"sub,omitempty"`
Name string `json:"name,omitempty"`
Data any `json:"data"`
Data2 any `json:"data2,omitempty"`
Data3 any `json:"data3,omitempty"`
}
// ServiceToken returns the configured unified service token — the first non-empty
@@ -66,7 +73,13 @@ func Ok(c *zip.Ctx, data any, more ...any) error {
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
// not HTTP code).
func Err(c *zip.Ctx, msg string) error {
return c.JSON(200, Response{Status: "error", Msg: msg})
return ErrCode(c, msg, "")
}
// ErrCode is Err carrying a machine-readable reason alongside the human message.
// ONE implementation writes the error envelope; Err is this with no reason to give.
func ErrCode(c *zip.Ctx, msg, code string) error {
return c.JSON(200, Response{Status: "error", Msg: msg, Code: code})
}
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
+97 -15
View File
@@ -4,6 +4,7 @@ package store
import (
"context"
"errors"
"strings"
"time"
@@ -37,11 +38,83 @@ import (
// read anywhere. Its ONLY resolution is org-only, at the ingest door
// (keys.resolve → /v1/iam/resolve-key), and only for a publishable key.
// UserByAccessKey resolves an opaque API key to the user it authenticates, or
// orm.ErrNotFound for an empty/unknown/unrecognized/publishable key. It never returns
// a wrong user: each SECRET shape (hk-/sk-) resolves through its own exact-match
// lookup, an sk- key whose row names no resolvable user fails closed rather than
// guessing one, and a public pk- resolves to nobody at all.
// ── why a key did not resolve ────────────────────────────────────────────────
//
// FAILING CLOSED AND FAILING SILENTLY ARE DIFFERENT THINGS, and this file used to do
// both. Every non-resolution collapsed into a bare orm.ErrNotFound, which the compat
// handler rendered as "the entity does not exist" — one sentence for causes that call
// for opposite actions from the holder. A user whose key was REVOKED went looking for
// a deleted organization instead of minting a new key, and a tenant admin's forgery
// attempt (the same-tenant pin below) was indistinguishable from a typo.
//
// The reason is therefore a VALUE, carried beside the error rather than baked into
// its text. It changes no decision here — every branch still refuses — so refusing
// and explaining stay orthogonal.
type KeyFailure string
const (
// KeyWrongDoor: a shape this door does not answer for. A pk- (or anything
// unrecognized) at the SECRET door, or a non-pk- at the publishable one. The
// credential may be perfectly valid — it was presented at the wrong door.
KeyWrongDoor KeyFailure = "key_wrong_door"
// KeyUnknown: a well-shaped key that no row bears. Never minted, already
// revoked, or — for the legacy hk- population — clobbered when a later mint
// overwrote schema.User.AccessKey (see keys.MintUserKey). The holder's cure is
// to mint a new key; nothing about their org is wrong.
KeyUnknown KeyFailure = "key_unknown"
// KeyForeignUser: an sk- row resolved, but it names a user in ANOTHER tenant.
// The same-tenant pin (userOwningKey) refused it. This is a SECURITY EVENT, not
// a user error, and it must never again look like one.
KeyForeignUser KeyFailure = "key_foreign_user"
// KeyDanglingUser: an sk- row resolved and named a same-tenant user that does
// not exist. A data-integrity fault in the key table, not the holder's doing.
KeyDanglingUser KeyFailure = "key_dangling_user"
// KeyNotPublishable: a real key addressed by its pk- half whose scope is not
// publish. The browser door refuses it precisely because it is a secret.
KeyNotPublishable KeyFailure = "key_not_publishable"
// KeyExpired: the row exists and is the right scope, but its lifetime has run
// out. Only the publishable door can report this — see the note on keyLive.
KeyExpired KeyFailure = "key_expired"
)
// KeyError is an orm.ErrNotFound that ALSO says why. It Unwraps to orm.ErrNotFound
// so every existing `errors.Is(err, orm.ErrNotFound)` caller keeps working untouched
// and unaware: the reason is strictly additive, and a caller that does not care
// about it is not made to.
type KeyError struct{ Reason KeyFailure }
func (e *KeyError) Error() string { return "key not resolved: " + string(e.Reason) }
func (e *KeyError) Unwrap() error { return orm.ErrNotFound }
// Reason extracts the failure reason from a key-resolution error. A plain
// orm.ErrNotFound (from a path that predates this type) reads as KeyUnknown, and
// anything else — a real store fault — reads as "" so a caller never reports an
// infrastructure failure as a bad credential.
func Reason(err error) KeyFailure {
var ke *KeyError
if errors.As(err, &ke) {
return ke.Reason
}
if errors.Is(err, orm.ErrNotFound) {
return KeyUnknown
}
return ""
}
func notFound(r KeyFailure) error { return &KeyError{Reason: r} }
// UserByAccessKey resolves an opaque API key to the user it authenticates, or a
// KeyError (an orm.ErrNotFound naming its cause) for an empty/unknown/unrecognized/
// publishable key. It never returns a wrong user: each SECRET shape (hk-/sk-)
// resolves through its own exact-match lookup, an sk- key whose row names no
// resolvable user fails closed rather than guessing one, and a public pk- resolves to
// nobody at all.
//
// NOTE ON EXPIRY: neither secret shape can report KeyExpired, because neither has an
// expiry to read. An hk- lives on the User row, which carries no lifetime at all; an
// sk- resolves through userOwningKey, which does not consult keyLive. Revocation is
// deletion (or clearing the field), so for a secret key "gone" is the only
// termination and KeyUnknown is the honest answer.
func UserByAccessKey(ctx context.Context, db orm.DB, key string) (*schema.User, error) {
key = strings.TrimSpace(key)
switch {
@@ -52,7 +125,7 @@ func UserByAccessKey(ctx context.Context, db orm.DB, key string) (*schema.User,
default:
// A pk- publishable half lands here with every other unrecognized value: it
// is WRITE-ONLY and never a principal (see the package note above). Fail closed.
return nil, orm.ErrNotFound
return nil, notFound(KeyWrongDoor)
}
}
@@ -61,7 +134,7 @@ func UserByAccessKey(ctx context.Context, db orm.DB, key string) (*schema.User,
func userByField(_ context.Context, db orm.DB, field, val string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](db).Filter(field+"=", val).First()
if err == orm.ErrNotFound {
return nil, orm.ErrNotFound
return nil, notFound(KeyUnknown)
}
return u, err
}
@@ -86,23 +159,25 @@ func userByField(_ context.Context, db orm.DB, field, val string) (*schema.User,
func userOwningKey(ctx context.Context, db orm.DB, field, val string) (*schema.User, error) {
k, err := orm.TypedQuery[schema.Key](db).Filter(field+"=", val).First()
if err == orm.ErrNotFound {
return nil, orm.ErrNotFound
return nil, notFound(KeyUnknown)
}
if err != nil {
return nil, err
}
owner, name := keyUserRef(k)
// Same-tenant pin: the resolved user MUST live in the key row's own org. A
// "/"-qualified User naming a foreign owner is a forgery attempt — fail closed.
// "/"-qualified User naming a foreign owner is a forgery attempt — fail closed,
// and say WHICH refusal this was: a cross-tenant reference is an attack signal
// and must not read to an operator as a mistyped key.
if owner == "" || name == "" || owner != k.Owner {
return nil, orm.ErrNotFound
return nil, notFound(KeyForeignUser)
}
u, err := GetUserByName(ctx, db, owner, name)
if err != nil {
return nil, err
}
if u == nil {
return nil, orm.ErrNotFound
return nil, notFound(KeyDanglingUser)
}
return u, nil
}
@@ -138,17 +213,24 @@ func keyUserRef(k *schema.Key) (owner, name string) {
func PublishableKeyByAccessKey(ctx context.Context, db orm.DB, key string, now time.Time) (*schema.Key, error) {
key = strings.TrimSpace(key)
if !strings.HasPrefix(key, "pk-") {
return nil, orm.ErrNotFound
return nil, notFound(KeyWrongDoor)
}
k, err := orm.TypedQuery[schema.Key](db).Filter("AccessKey=", key).First()
if err == orm.ErrNotFound {
return nil, orm.ErrNotFound
return nil, notFound(KeyUnknown)
}
if err != nil {
return nil, err
}
if k.Scope != schema.KeyScopePublish || !keyLive(k, now) {
return nil, orm.ErrNotFound
// Two different refusals, told apart. "Not publishable" means the holder used a
// secret key's public half at the ingest door and should present its pk-;
// "expired" means the right key simply ran out and must be re-minted. Collapsing
// them sent the second holder hunting for a configuration error they did not have.
if k.Scope != schema.KeyScopePublish {
return nil, notFound(KeyNotPublishable)
}
if !keyLive(k, now) {
return nil, notFound(KeyExpired)
}
return k, nil
}
+96
View File
@@ -203,3 +203,99 @@ func TestPublishableKeyByAccessKey(t *testing.T) {
}
}
}
// ── the reason, not just the refusal ─────────────────────────────────────────
// Every refusal still fails closed AND now says which refusal it was. "the entity
// does not exist" was one sentence for causes that call for opposite actions: a
// revoked key needs re-minting, a pk- at the secret door needs the other door, and a
// cross-tenant key row is an ATTACK — none of which the holder or an operator could
// tell apart. The reason is additive: errors.Is(err, orm.ErrNotFound) still holds for
// every case, so no existing caller changes behavior.
func TestUserByAccessKey_ReasonsAreDistinguishable(t *testing.T) {
db := memDB(t)
ctx := context.Background()
seedKeyUser(t, db, "hanzo", "alice", "alice@hanzo.ai", "hk-live-ALICEKEY")
seedKey(t, db, "hanzo", "org-key", "", "pk-live-ORGONLY", "sk-live-ORGONLY")
// An attacker's own-org key naming a foreign identity — the same-tenant pin.
seedKey(t, db, "attackerOrg", "forge", "victimorg/ceo", "pk-live-FORGE", "sk-live-FORGE")
// A key naming a same-tenant user that does not exist — a dangling row.
seedKey(t, db, "hanzo", "dangling", "hanzo/ghost", "pk-live-GHOST", "sk-live-GHOST")
for _, tc := range []struct {
name string
key string
want KeyFailure
}{
{"revoked or never-minted hk", "hk-live-NOSUCH", KeyUnknown},
{"unknown sk", "sk-live-NOSUCH", KeyUnknown},
{"a pk- at the SECRET door", "pk-live-ORGONLY", KeyWrongDoor},
{"an unrecognized shape", "fw_deadbeef", KeyWrongDoor},
{"empty", "", KeyWrongDoor},
{"cross-tenant key row is a SECURITY event", "sk-live-FORGE", KeyForeignUser},
{"user-less key row cannot name a principal", "sk-live-ORGONLY", KeyForeignUser},
{"key naming a nonexistent same-tenant user", "sk-live-GHOST", KeyDanglingUser},
} {
got, err := UserByAccessKey(ctx, db, tc.key)
if got != nil {
t.Fatalf("%s: resolved %+v, want nil — every case must still fail closed", tc.name, got)
}
if !errors.Is(err, orm.ErrNotFound) {
t.Fatalf("%s: err=%v, want it to still satisfy errors.Is(_, orm.ErrNotFound)", tc.name, err)
}
if r := Reason(err); r != tc.want {
t.Errorf("%s: reason = %q, want %q", tc.name, r, tc.want)
}
}
}
// The publishable door tells its three refusals apart. This is the trio the cloud
// fork's own test annotated as "unknown / not publishable / expired" while having no
// way to distinguish them — the annotation is now executable.
func TestPublishableKeyByAccessKey_ReasonsAreDistinguishable(t *testing.T) {
db := memDB(t)
ctx := context.Background()
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
seedPublishKey(t, db, "hanzo", "site-exp", "pk-live-EXPIRED", "2020-01-01T00:00:00Z")
seedKey(t, db, "hanzo", "secret", "hanzo/alice", "pk-live-SECRETHALF", "sk-live-x")
for _, tc := range []struct {
name string
key string
want KeyFailure
}{
{"unknown", "pk-live-NOSUCH", KeyUnknown},
{"not publishable", "pk-live-SECRETHALF", KeyNotPublishable},
{"expired", "pk-live-EXPIRED", KeyExpired},
{"not a pk- at all", "sk-live-x", KeyWrongDoor},
} {
k, err := PublishableKeyByAccessKey(ctx, db, tc.key, now)
if k != nil {
t.Fatalf("%s: resolved %+v, want nil", tc.name, k)
}
if !errors.Is(err, orm.ErrNotFound) {
t.Fatalf("%s: err=%v, want errors.Is(_, orm.ErrNotFound)", tc.name, err)
}
if r := Reason(err); r != tc.want {
t.Errorf("%s: reason = %q, want %q", tc.name, r, tc.want)
}
}
}
// A real store fault is NOT a bad credential. Reason yields "" for anything that is
// not a not-found, so a caller can never render infrastructure trouble to a user as
// "your key is invalid".
func TestReason_StoreFaultIsNotAKeyFailure(t *testing.T) {
if r := Reason(errors.New("dial tcp: connection refused")); r != "" {
t.Fatalf("Reason(store fault) = %q, want \"\"", r)
}
if r := Reason(nil); r != "" {
t.Fatalf("Reason(nil) = %q, want \"\"", r)
}
// A bare orm.ErrNotFound from a path predating KeyError still reads as unknown.
if r := Reason(orm.ErrNotFound); r != KeyUnknown {
t.Fatalf("Reason(orm.ErrNotFound) = %q, want %q", r, KeyUnknown)
}
}