team: one identity seam, IAM lane beside the HS256 arm
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m10s
CI/CD / image (push) Failing after 15s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped

Every team surface resolved its caller by decoding an HS256 token itself, so
"who is calling" was answered in six places against one signing key. This adds
identity (apps/team/account.go): ONE seam that resolves a caller, and the only
place a credential's algorithm is routed on. account, typed, files, billing,
collab and the transactor hold the seam instead of the secret, and three of them
stop importing the condemned package — account.go is the only reader left inside
apps/team.

Verification, tenancy and authorization are three questions, answered separately:

  - VERIFICATION is cloud's own IAM validator, narrowed. A signature from a
    trusted issuer says IAM minted the token, never that it was minted FOR this
    surface — IAM's signer emits the same claims into an access token and an
    id_token but for aud/tokenType/nonce. A session door must say which it
    means, so the lane takes access tokens whose audience this deployment NAMES.
    The boundary's no-audience-gate posture is right for an API door and wrong
    here; the divergence is stated at the pin.
  - TENANCY is the home org from the signed membership set, never `owner`.
    `owner` carries the application's org, so it is chosen by whichever app the
    caller authenticated through, and a lane reading it scopes every store query
    to an org the caller selected. No membership set means no home, which is
    also every machine credential — a team session is a person's.
  - IDENTITY is the `sub` claim, resolved through the store. The canonical user
    id falls back to preferred_username, and an account id derives from a UUID
    verbatim, so a token with no sub whose username is a colleague's account uuid
    resolved to the colleague. Subject-only, confirmed against the rows a login
    created.

An IAM credential never leaves the seam: it is an estate-wide bearer held in an
HttpOnly cookie so page JS cannot read it, and the account RPC echoes a caller's
token back to page JS.

Workspace authorization on the IAM lane is the membership rows (admit) — the
server decides, the caller signs nothing. The transactor keeps its path-borne
workspace token and gains no ambient lane: a WebSocket is exempt from CORS, so a
cookie-borne credential would make the Origin list the data plane's only access
control, and that list no longer carries a wildcard either.

The existing credential answers first on every carrier, so a client that has one
behaves exactly as it did and the new lane serves only a browser holding nothing
else. meet decides a room join and does not own the workspace rows, so team
publishes them on the internal plane and meet asks, off the boundary's own
attestation rather than off headers a client can set. analytics is untouched:
its trust order already resolves a validated IAM bearer ahead of the team token.

The HS256 arm is deleted when login mints IAM-only and front/love/
analytics-collector verify IAM; getWorkspaceInfo is the one surface that still
needs a client change first.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-08-04 21:55:46 -07:00
parent 4c1686e9e1
commit c8b7c310da
28 changed files with 2109 additions and 259 deletions
+95 -19
View File
@@ -56,8 +56,10 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
"gopkg.in/yaml.v3"
)
@@ -342,16 +344,17 @@ func mint(s *cloud.Service[state], c *zip.Ctx) error {
if room == "" {
return zip.ErrBadRequest("roomName required")
}
// Say what was actually checked. meet performs NO membership lookup — it has
// no members table, no store, and makes no call to IAM. Membership was decided
// upstream, at the IAM login that minted this session, and is already signed
// into the token as `workspace`. All that happens here is a refusal to WIDEN
// that: the room asked for must belong to the workspace the token already
// names. Claiming "not a member" describes a determination this code never
// makes, and reads as a second authorization system where there is none.
t, ok := st.admits(room, c.Header("Authorization"))
// Say what was actually checked, and it is now one of two things. On the HS256
// arm meet performs no membership lookup: membership was decided upstream at
// the login that minted the session and is signed into the token as
// `workspace`, and all that happens here is a refusal to WIDEN it. On the IAM
// lane there is no such claim, so the workspace rows are asked directly — and
// then "not a member" IS the determination being made. One message covers both
// because it names the fact, not the mechanism: this caller is not admitted to
// this room.
j, ok := st.admits(c, room)
if !ok {
return zip.Errorf(http.StatusUnauthorized, "token workspace does not match this room")
return zip.Errorf(http.StatusUnauthorized, "not admitted to this room")
}
// THE IDENTITY IS THE TOKEN'S, NOT THE BODY'S. LiveKit uses `sub` as the
// participant identity and EJECTS an existing participant on a duplicate — so
@@ -364,7 +367,7 @@ func mint(s *cloud.Service[state], c *zip.Ctx) error {
// checked: verifying it belongs to the caller would need the person<->account
// mapping from apps/team, whereas the token already carries an identity that IS
// the caller. One fewer seam, and no lookup to get wrong.
identity := strings.TrimSpace(t.Account)
identity := j.account
if identity == "" {
return zip.Errorf(http.StatusUnauthorized, "token carries no account")
}
@@ -384,8 +387,45 @@ func workspace(room string) string {
return ws
}
// admits decides whether the bearer may join room. Every clause is a refusal; there
// is no branch that admits by default.
// joiner is who may join, once a lane has decided it: the account LiveKit takes as
// the participant identity, and nothing else. A lane that cannot fill it does not
// admit anyone.
type joiner struct{ account string }
// admits decides whether the caller may join room, on either of two lanes. Every
// clause is a refusal; there is no branch that admits by default.
//
// IAM LANE, selected on the boundary's OWN ATTESTATION (principal.Minted) and on
// nothing else. It used to select on `c.Org() != "" && c.User() != ""`, and both
// disjuncts are forgeable — the same defect agency.go names and fixed: X-Org-Id
// survives the boundary on the anonymous path by design, and in a process where
// the boundary is not installed at all (a hand-written plugin main, which is
// exactly what this app has) NOTHING strips either header, so both are the
// client's. Here that bought a LiveKit seat under a chosen identity, and LiveKit
// EVICTS an existing participant on a duplicate `sub` — so a forged header ejected
// a colleague from a live call and impersonated them to the room. The attestation
// is absent when no boundary ran, which falls through to the HS256 arm and refuses
// rather than admitting whatever was typed.
//
// The org and the SUBJECT are read from that attestation, never off c.Org()/
// c.User() and never off the body. p.Subject is the `sub` claim verbatim: p.User
// falls back to preferred_username, so two identities can present the same User and
// a lookup keyed on it can be handed one token and address another's row.
//
// A MACHINE CREDENTIAL IS NOT A PERSON, and the subject requirement is what
// excludes one. The boundary stamps an org and a user for an sk- API key too, so
// "has an org and a user" would have put a machine on the lane whose whole question
// is "which human is in this room" — and LiveKit would then seat it under whatever
// identity the account lookup returned. A key principal carries no `sub`, so
// requiring one refuses it structurally rather than by naming credential kinds.
//
// The verdict says nothing about a workspace, so the workspace ROWS decide: apps/team
// owns them and answers over the internal plane (plane.TeamMember) with the caller's
// role and the account id it joined the subject to. A caller with no row, or one
// whose role is not privileged, is refused, and so is a peer that cannot answer —
// an unreachable authority is a refusal, never an assumption.
//
// HS256 ARM, unchanged, and deleted with the rest of the second bearer authority:
//
// - the token must VERIFY against SERVER_SECRET (signature, exp, nbf) — so a forged
// or stale session is not a member;
@@ -401,22 +441,58 @@ func workspace(room string) string {
// check was inert and every guest was admitted. selectWorkspace now signs the real
// workspace role, and an ABSENT role is unprivileged, so a token that has not
// proven a role is refused rather than assumed to be a member.
func (s state) admits(room, auth string) (*token.Token, bool) {
raw := bearer(auth)
func (s state) admits(c *zip.Ctx, room string) (joiner, bool) {
if p, ok := principal.Minted(c); ok && p.Subject != "" && p.Org != "" {
return s.admitsMember(c, room, p)
}
raw := bearer(c.Header("Authorization"))
if raw == "" {
return nil, false
return joiner{}, false
}
t, err := token.Decode(raw, s.teamSecret, true)
if err != nil {
return nil, false
return joiner{}, false
}
if t.Workspace == "" || t.Workspace != workspace(room) {
return nil, false
return joiner{}, false
}
if !t.Privileged() {
return nil, false
return joiner{}, false
}
return joiner{account: strings.TrimSpace(t.Account)}, true
}
// admitsMember is the IAM lane's authorization: ask the process that owns the
// workspace rows. Both halves of the question come from the ATTESTED principal —
// the org rides the caller (never an argument, so a caller cannot ask about another
// tenant's workspace) and the subject is the attested `sub`.
func (s state) admitsMember(c *zip.Ctx, room string, p principal.Principal) (joiner, bool) {
ws := workspace(room)
if ws == "" {
return joiner{}, false
}
m, err := cloud.Ask[plane.MemberIn, plane.Member](cloud.As(c, p.Org), "team", plane.TeamMember,
&plane.MemberIn{Workspace: ws, Subject: p.Subject})
if err != nil || m == nil || !m.Member {
return joiner{}, false
}
if !privileged(m.Role) {
return joiner{}, false
}
return joiner{account: strings.TrimSpace(m.Account)}, true
}
// privileged is token.Privileged over a role the SERVER read rather than one a
// token signed. Same vocabulary, same fail-closed shape: an unknown or absent role
// is not privileged, so a role added to the invite set tomorrow starts without a
// seat in a colleague's meeting instead of silently holding one.
func privileged(role string) bool {
switch strings.TrimSpace(role) {
case token.RoleOwner, token.RoleAdmin, token.RoleMember:
return true
default:
return false
}
return t, true
}
// bearer extracts the token from an "Authorization: Bearer <t>" header (scheme
+176 -6
View File
@@ -22,7 +22,9 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/internal/iamtest"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -593,6 +595,54 @@ func TestSigningUsesTheFilesSecretVerbatim(t *testing.T) {
// ── the tenant boundary, at the level that enforces it ───────────────────────
// admitsOn drives one request through THE REAL IDENTITY BOUNDARY and runs
// st.admits against its live context — which is pooled and recycled the moment the
// handler returns, so the call has to happen inside it.
//
// boundary selects what a test is modelling, and the distinction is the whole
// point of these cases:
//
// - true — cloud.IdentityMiddleware installed, exactly as Serve installs it.
// Client-sent identity headers are STRIPPED and the attestation is minted from
// the token or not at all.
// - false — no boundary, which is what apps/meet's own plugin main actually runs.
// Nothing strips anything, so every identity header on the wire is the
// client's. A lane that reads one here is reading whatever was typed.
func admitsOn(t *testing.T, st state, room, auth string, headers map[string]string, boundary bool) (j joiner, ok bool) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if boundary {
app.Use(cloud.IdentityMiddleware(&cloud.Config{IAMIssuer: iamtest.Issuer, JWKSURL: jwksURL}))
}
app.Use(cloud.Bridge())
app.Get("/probe", func(c *zip.Ctx) error {
j, ok = st.admits(c, room)
return c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
for k, v := range headers {
req.Header.Set(k, v)
}
if _, err := app.Test(req); err != nil {
t.Fatalf("probe: %v", err)
}
return j, ok
}
// jwksURL is where the per-test issuer publishes; set by iamIssuer below.
var jwksURL string
// iamIssuer stands up the signing issuer these tests mint IAM tokens with.
func iamIssuer(t *testing.T) *iamtest.Issuer0 {
t.Helper()
iss := iamtest.New(t)
jwksURL = iss.URL
return iss
}
// TestAdmitsBindsRoomToTheSignedWorkspace tests `admits`, NOT the workspace() helper.
// That distinction is the whole point: TestWorkspaceOfRoom pins the parse in isolation
// and constrains nothing about how admits USES it, so mutating the comparison from
@@ -614,16 +664,16 @@ func TestAdmitsBindsRoomToTheSignedWorkspace(t *testing.T) {
workspaceA + "-evil_standup_1", // suffixed segment
workspaceA + workspaceA + "_standup_1", // segment 0 starts with the real uuid
} {
if _, ok := st.admits(room, member(workspaceA)); ok {
if _, ok := admitsOn(t, st, room, member(workspaceA), nil, false); ok {
t.Errorf("admitted room %q for workspace %q — segment 0 is not an exact match", room, workspaceA)
}
}
// The exact segment is admitted, so the test discriminates rather than always failing.
if _, ok := st.admits(roomIn(workspaceA), member(workspaceA)); !ok {
if _, ok := admitsOn(t, st, roomIn(workspaceA), member(workspaceA), nil, false); !ok {
t.Fatal("refused the exact-workspace room; the check is not discriminating")
}
// And the converse direction: a member of A cannot enter B's room.
if _, ok := st.admits(roomIn(workspaceB), member(workspaceA)); ok {
if _, ok := admitsOn(t, st, roomIn(workspaceB), member(workspaceA), nil, false); ok {
t.Error("a member of workspace A was admitted to a workspace B room")
}
}
@@ -637,16 +687,16 @@ func TestAdmitsRefusesUnboundSession(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
unbound := "Bearer " + session(t, "", teamSecret, nil, hour)
for _, room := range []string{"_standup_1", "_", "_anything"} {
if _, ok := st.admits(room, unbound); ok {
if _, ok := admitsOn(t, st, room, unbound, nil, false); ok {
t.Errorf("an unbound session was admitted to %q", room)
}
}
// It is also refused for a normal room, and a BOUND session is admitted — so the
// refusal is about the empty claim, not about rooms in general.
if _, ok := st.admits(roomIn(workspaceA), unbound); ok {
if _, ok := admitsOn(t, st, roomIn(workspaceA), unbound, nil, false); ok {
t.Error("an unbound session was admitted to a real workspace room")
}
if _, ok := st.admits(roomIn(workspaceA), "Bearer "+session(t, workspaceA, teamSecret, nil, hour)); !ok {
if _, ok := admitsOn(t, st, roomIn(workspaceA), "Bearer "+session(t, workspaceA, teamSecret, nil, hour), nil, false); !ok {
t.Fatal("a bound member was refused; the test is not discriminating")
}
}
@@ -839,3 +889,123 @@ func TestHealthLeaksNothingUnauthenticated(t *testing.T) {
})
}
}
// TestForgedIdentityHeadersBuyNothing is the F1 regression, and it is the reason
// these tests run the real boundary.
//
// meet used to select its IAM lane on `c.Org() != "" && c.User() != ""` — two
// HEADERS. In a process with no identity boundary installed nothing strips them,
// and apps/meet's own plugin main is exactly such a process. So any caller could
// name themselves, take the lane, and be issued a LiveKit seat under a chosen
// identity — and LiveKit EVICTS an existing participant on a duplicate `sub`, so
// the forgery ejected a colleague from a live call and impersonated them to the
// room.
//
// The lane now selects on the boundary's own attestation, which no header can
// create. Both shapes are pinned: with no boundary the headers are inert, and with
// the boundary they are stripped before anything reads them.
func TestForgedIdentityHeadersBuyNothing(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
iamIssuer(t)
forged := map[string]string{
"X-Org-Id": "acme",
"X-User-Id": "11111111-2222-4333-8444-555555555555",
}
for _, boundary := range []bool{false, true} {
if j, ok := admitsOn(t, st, roomIn(workspaceA), "", forged, boundary); ok {
t.Fatalf("SECURITY (boundary=%v): forged identity headers bought a seat as %q", boundary, j.account)
}
}
// And they do not upgrade a caller who holds nothing else, nor downgrade one who
// holds a real HS256 session: the headers are simply not an input.
hour := time.Now().Add(time.Hour).Unix()
good := "Bearer " + session(t, workspaceA, teamSecret, nil, hour)
if _, ok := admitsOn(t, st, roomIn(workspaceA), good, forged, false); !ok {
t.Fatal("forged headers displaced a valid HS256 session")
}
}
// TestIAMLaneTakesTheAttestedPrincipalAndFailsClosed proves the other half: a REAL
// IAM access token, through the REAL boundary, does take the IAM lane — and on that
// lane the authority is apps/team over the internal plane. There is no team peer
// here, so the ask cannot be answered, and an authority that cannot answer is a
// refusal rather than an assumption of membership.
func TestIAMLaneTakesTheAttestedPrincipalAndFailsClosed(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
iss := iamIssuer(t)
hour := time.Now().Add(time.Hour).Unix()
// A token that WOULD be admitted on the HS256 arm, presented by the same caller,
// so the refusal below is about the lane and not about the credential.
good := "Bearer " + session(t, workspaceA, teamSecret, nil, hour)
if _, ok := admitsOn(t, st, roomIn(workspaceA), good, nil, true); !ok {
t.Fatal("the HS256 arm refused a bound member; the test is not discriminating")
}
iamTok := "Bearer " + iss.Sign(t, iamtest.Claims{Sub: "11111111-2222-4333-8444-555555555555", Owner: "acme"})
if _, ok := admitsOn(t, st, roomIn(workspaceA), iamTok, nil, true); ok {
t.Fatal("the IAM lane admitted a caller with no answer from the workspace rows")
}
// A room that names no workspace is refused before anything is asked.
if _, ok := admitsOn(t, st, "no-separator", iamTok, nil, true); ok {
t.Fatal("the IAM lane admitted a room that names no workspace")
}
}
// TestPrivilegedIsFailClosed pins the role predicate the IAM lane grants on. It is
// the same vocabulary token.Privileged reads, over a role the SERVER read: a guest
// is reduced, and an absent or unrecognised role is not privileged, so a role added
// to the invite set tomorrow starts without a seat in a colleague's meeting.
func TestPrivilegedIsFailClosed(t *testing.T) {
for _, role := range []string{token.RoleOwner, token.RoleAdmin, token.RoleMember, " owner "} {
if !privileged(role) {
t.Errorf("privileged(%q) = false, want true", role)
}
}
for _, role := range []string{"guest", "", " ", "GUEST", "Owner", "auditor"} {
if privileged(role) {
t.Errorf("privileged(%q) = true — an unproven role must not confer a seat", role)
}
}
}
// TestMachineCredentialIsNotAPerson is the F6/F7 regression.
//
// The identity boundary stamps an org AND a user for an sk- API key, so a lane
// selected on "has an org and a user" put a MACHINE on the lane whose whole
// question is which human is in this room — and LiveKit seats a participant under
// whatever identity it is handed, evicting the live one on a duplicate. A key
// principal carries no `sub`, so requiring one refuses it structurally rather than
// by trying to enumerate credential kinds.
func TestMachineCredentialIsNotAPerson(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
iamIssuer(t)
// The shape a key principal has after the boundary: an attested org and user,
// and no subject.
for _, p := range []principal.Principal{
{Org: "acme", User: "sk-key-user"}, // API key: no sub
{Org: "acme", User: "hanzo/robot", Subject: ""}, // client_credentials
{Org: "", User: "u", Subject: "has-a-sub"}, // no tenant
} {
if _, ok := admitsWithPrincipal(t, st, roomIn(workspaceA), p); ok {
t.Fatalf("SECURITY: a principal with no human subject was admitted: %+v", p)
}
}
}
// admitsWithPrincipal mints an attestation directly — the one way to model what the
// boundary produces for a credential kind this test cannot mint (an API key is
// resolved against IAM, not signed). principal.Mint is the boundary's own call, so
// this exercises exactly the value admits() reads.
func admitsWithPrincipal(t *testing.T, st state, room string, p principal.Principal) (j joiner, ok bool) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Get("/probe", func(c *zip.Ctx) error {
principal.Mint(c, p)
j, ok = st.admits(c, room)
return c.String(http.StatusOK, "ok")
})
if _, err := app.Test(httptest.NewRequest(http.MethodGet, "/probe", nil)); err != nil {
t.Fatalf("probe: %v", err)
}
return j, ok
}
+11 -3
View File
@@ -148,6 +148,13 @@ func OrgOf(user, org string) (string, bool) {
type Principal struct {
Org string
User string
// Subject is the token's `sub` VERBATIM. User is the canonical id and falls
// back to preferred_username when a token carries no sub, which makes it an
// attribution key rather than an identity key: two subjects can present the
// same User. A consumer that RESOLVES A RECORD from the caller — an account
// row, a membership — keys on this and refuses it empty, so it can never be
// handed one identity's token and address another's row.
Subject string
}
// mintedSlot names the request-local slot the boundary parks its attestation in.
@@ -169,7 +176,7 @@ type mintedSlot struct{}
// must not depend on its position asks THIS instead — a fact only the boundary
// can state, absent when the boundary did not run, which fails closed to
// anonymous rather than open to forged.
// Both fields are CLONED. A value read off a request is a zero-copy view into
// EVERY field is CLONED. A value read off a request is a zero-copy view into
// the reused fasthttp buffer, and this one is retained past the read — it becomes
// a map key in the edge sensor and a column in a meter — so an un-owned copy
// would mutate into unrelated bytes on the next request through that worker.
@@ -177,8 +184,9 @@ type mintedSlot struct{}
// clones for exactly this reason.)
func Mint(c *zip.Ctx, p Principal) {
c.Fiber().Locals(mintedSlot{}, Principal{
Org: strings.Clone(p.Org),
User: strings.Clone(p.User),
Org: strings.Clone(p.Org),
User: strings.Clone(p.User),
Subject: strings.Clone(p.Subject),
})
}
+401 -57
View File
@@ -19,6 +19,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -88,10 +89,12 @@ type api struct {
trans *transServer
cfg config
log luxlog.Logger
// verify is cloud's RS256/JWKS IAM token validator (cloud.NewTokenValidator —
// the SAME trust anchor as the identity boundary). The OAuth callback derives
// the tenant from ITS verdict, never from unverified claims.
verify func(string) (cloud.VerifiedIdentity, error)
// ident is the identity seam every team surface resolves its caller through:
// cloud's RS256/JWKS IAM validator (the SAME trust anchor as the identity
// boundary), the HS256 secret, and the membership rows. The OAuth callback
// derives its tenant from that validator's verdict, never from unverified
// claims — one validator, not a second copy beside the seam holding it.
ident *identity
// commerce answers CheckEntitlement(org, "team") at workspace select — nil
// (not co-resident) is an infra absence and never blocks login.
commerce types.CommerceClient
@@ -494,7 +497,7 @@ func (g *api) establishSession(ctx context.Context, access string) (account, tok
}
// AccountUuid = the IAM sub (derived to a stable UUID when the sub is not one).
account = accountID(sub)
id, err := g.verify(access)
id, err := g.ident.verify(access)
if err != nil {
return "", "", "org_failed", err
}
@@ -564,17 +567,30 @@ func (g *api) establishSession(ctx context.Context, access string) (account, tok
// map so token.Generate's JSON marshal is stable and the decode side
// (orgsFromExtra) reads it back with no SDK dependency in the token layer.
func orgsClaim(orgs []model.OrgRef, home string) []map[string]any {
out := make([]map[string]any, 0, len(orgs)+1)
refs := homeOrgs(orgs, home)
out := make([]map[string]any, 0, len(refs))
for _, o := range refs {
out = append(out, map[string]any{"org": o.Org, "role": o.Role})
}
return out
}
// homeOrgs is that rule itself: the verified membership set, deduped, with the home
// tenant guaranteed present. It is shared by the login mint above and by the IAM
// lane's caller (identity.iam), so a person enumerates the SAME orgs whichever
// credential they arrive with.
func homeOrgs(orgs []model.OrgRef, home string) []model.OrgRef {
out := make([]model.OrgRef, 0, len(orgs)+1)
seen := map[string]bool{}
for _, o := range orgs {
if o.Org == "" || seen[o.Org] {
continue
}
seen[o.Org] = true
out = append(out, map[string]any{"org": o.Org, "role": o.Role})
out = append(out, o)
}
if home != "" && !seen[home] {
out = append(out, map[string]any{"org": home, "role": "admin"})
out = append(out, model.OrgRef{Org: home, Role: "admin"})
}
return out
}
@@ -640,7 +656,13 @@ func (g *api) setCookie(c *zip.Ctx) error {
// cookie. Storing an unverified, caller-supplied value is a login-CSRF /
// session-fixation vector — an attacker could pin a cookie the victim's browser
// then presents as authenticated. Only a token THIS service signed is accepted.
if _, err := token.Decode(body.Token, g.cfg.serverSecret, true); err != nil {
//
// The HS256 arm ONLY, deliberately: this writes account-token, and the IAM
// cookie beside it is minted by the OAuth callback out of a code exchange the
// browser itself started. Accepting a caller-supplied IAM token here would add a
// second, caller-driven writer for that cookie — a session-fixation surface the
// callback does not have.
if _, err := g.ident.hs256(body.Token); err != nil {
return zip.ErrUnauthorized("invalid session token")
}
g.setSessionCookie(c, authCookie, body.Token, int(sessionTokenTTL.Seconds()))
@@ -864,25 +886,25 @@ func (g *api) resolveWorkspace(ctx context.Context, orgs []model.OrgRef, account
}
}
// getWorkspaceInfo returns info for THE workspace the caller is scoped to — the
// one selectWorkspace already minted into the session token's `workspace` claim,
// resolved owner_org-scoped by (org, uuid). It NEVER falls back to the caller's
// first workspace: a token with no workspace claim (an account/login token that
// has not selected a workspace yet) is a clean WorkspaceNotFound, so the client is
// forced through the explicit selectWorkspace step rather than being silently
// handed an arbitrary one.
// getWorkspaceInfo returns info for THE workspace the caller's CREDENTIAL is
// scoped to — the one selectWorkspace already minted into the workspace token's
// `workspace` claim, resolved owner_org-scoped by (org, uuid). It NEVER falls back
// to the caller's first workspace: a credential that pins no workspace (an
// account/login token that has not selected one, and every IAM caller, which pins
// nothing by construction) is a clean WorkspaceNotFound, so the client is forced
// through the explicit selectWorkspace step rather than being silently handed an
// arbitrary one.
func (g *api) getWorkspaceInfo(c *zip.Ctx) error {
t, _, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return g.fail(c, statusUnauthorized(err.Error()))
}
if t.Workspace == "" {
if cl.workspace == "" {
return g.fail(c, statusWorkspaceNotFound(""))
}
org := t.Org()
ws, err := g.accounts.WorkspaceByUUID(c.Context(), org, t.Workspace)
ws, err := g.accounts.WorkspaceByUUID(c.Context(), cl.org, cl.workspace)
if err != nil {
return g.fail(c, statusWorkspaceNotFound(t.Workspace))
return g.fail(c, statusWorkspaceNotFound(cl.workspace))
}
return g.ok(c, toWorkspaceInfo(ws))
}
@@ -908,58 +930,380 @@ func (g *api) getSocialIds(c *zip.Ctx) error {
}})
}
// ── helpers ───────────────────────────────────────────────────────────────────
// ── the identity seam ─────────────────────────────────────────────────────────
// sessionToken decodes AND verifies (signature + expiry) the request's HS256
// bearer/cookie token — the one this service minted. bearer takes precedence over
// the cookie. It is the ONE place a team session token is turned into a principal,
// shared by the account RPC and the files plane.
//
// The SPA sends OUR HS256 token, not an IAM RS256 JWT: an IAM bearer would simply
// fail the HMAC check (ErrSignature) and be rejected here, so there is no separate
// algorithm routing to maintain (why token.Alg was removed). token.Decode with
// verify=true also enforces `exp`/`nbf`, so a captured expired token is refused.
func sessionToken(c *zip.Ctx, secret string) (*token.Token, string, error) {
raw := bearer(c)
if raw == "" {
raw = c.Fiber().Req().Cookies(authCookie)
}
if raw == "" {
return nil, "", fmt.Errorf("no token")
}
t, err := token.Decode(raw, secret, true)
if err != nil {
return nil, "", err
}
if t.Account == "" {
return nil, "", fmt.Errorf("token has no account")
}
return t, raw, nil
// identity is what every team surface turns a credential into a caller with, and
// the ONE place a credential's algorithm is routed on. It composes three answers
// and braids none of them: VERIFICATION (an IAM access token against the IAM JWKS,
// or the HS256 signature), ACCOUNT RESOLUTION (accountID over the IAM subject —
// the join establishSession stores the account's rows under), and WORKSPACE
// AUTHORIZATION (the membership rows, admit).
type identity struct {
// verify is cloud's RS256/JWKS IAM validator (cloud.NewTokenValidator) — the
// SAME trust anchor as the identity boundary and as the OAuth callback's tenant
// derivation, so a token any one of them accepts is a token all three accept.
verify func(string) (cloud.VerifiedIdentity, error)
// secret is SERVER_SECRET, the key of the HS256 arm.
secret string
// accounts is the membership authority. On the IAM lane nothing about a
// workspace is signed, so these rows ARE the authorization.
accounts *accountStore
// audience is the set of IAM apps whose access tokens this deployment accepts
// as a TEAM SESSION. See identity.iam for why team gates on it when the
// identity boundary deliberately does not.
audience map[string]bool
}
// account resolves (AccountUuid, org, token) from the request's verified session
// token. The org is the token's SIGNED extra.org claim — the tenant key for every
// account-store query — never a client header.
// caller is who a team surface is talking to. It is the WHOLE answer: no surface
// reads a claim off a credential for itself, so no surface can disagree with this
// one about who is calling.
type caller struct {
// account is the team AccountUuid.
account string
// org is the IAM tenant every account-store query is scoped to.
org string
// orgs is the home-safe membership set the cross-org surfaces enumerate.
orgs []model.OrgRef
// user is the IAM `<owner>/<name>` id, the key IAM's get-user takes for a
// mid-session membership refresh. Empty when the credential names no username.
user string
// workspace is the workspace the CREDENTIAL pinned itself to. Empty on the IAM
// lane, which pins nothing: what an IAM caller may touch is decided per request
// by admit against the rows, never by a claim the caller carries.
workspace string
// raw is the HS256 credential exactly as presented, and it is EMPTY ON THE IAM
// LANE — deliberately, structurally, and not as a rule each caller remembers.
//
// The account RPC echoes this back to the SPA as its session token, and the SPA
// is page JS. An IAM access token is an estate-wide RS256 bearer that reaches
// the gateway, KMS and every other service; the login flow puts it in an
// HttpOnly cookie precisely so script can never read it. Echoing it here would
// hand it straight back to the script the cookie flag exists to keep it from —
// one unauthenticated-looking RPC, and the caller's whole platform credential is
// in a variable. So the IAM lane carries no credential OUT of this file at all,
// and a future echo site cannot reintroduce the leak by forgetting.
raw string
// iam reports which lane resolved this caller. It exists so a surface can grant
// on rows instead of on a signed workspace claim, not so it can re-derive trust.
iam bool
}
// who resolves the caller of a team surface, on either of two lanes.
//
// THE IAM LANE is an IAM access token — Authorization: Bearer, else the
// hanzo_iam_token cookie the login flow already set — verified against the IAM
// JWKS, narrowed to this deployment's own audience, and resolved to a team account
// through the store by its SUBJECT (identity.iam).
//
// THE HS256 ARM is the token this service minted, semantics unchanged: bearer
// first and the account-token cookie after, signature and exp/nbf enforced. It is
// deleted when login mints IAM-only and front/love/analytics-collector verify IAM.
//
// ONE SURFACE IS NOT DUAL-READ YET, and it blocks that deletion: getWorkspaceInfo
// answers for the workspace the CREDENTIAL pins, and the IAM lane pins none by
// construction — only selectWorkspace's HS256 mint does. So the workspace a client
// is "in" still has to travel as a claim. Deleting the arm means the front NAMING
// the workspace on that call (as it already does for selectWorkspace) and this
// authorizing it through admit, the same way the transactor and files planes
// already do. That is a client change, which is why it is a later phase and not
// this one.
//
// THE ORDER IS WHAT MAKES THIS PHASE INERT, and it is the existing credential
// first on BOTH carriers:
//
// - Authorization is answered by the bearer alone. A signed-in browser carries an
// IAM cookie beside its HS256 bearer, so consulting the cookie for a request
// that already presented a bearer would move every current client onto the new
// lane at once.
// - with no bearer, account-token is read BEFORE hanzo_iam_token, and an
// account-token that is PRESENT answers alone — a stale one is refused rather
// than falling through. The two cookies coexist for the whole overlap and are
// not interchangeable: the HS256 one can PIN A WORKSPACE and the IAM one
// cannot, so preferring the IAM cookie silently widened the collaborator planes
// from "the workspace this token names" to "any workspace you are a member of",
// and made getWorkspaceInfo answer WorkspaceNotFound where the pin used to
// answer. Falling through on expiry would be the same widening on a timer: a
// session that used to end in a 401 would quietly continue with a different
// reach.
//
// So the rule is one sentence for every carrier: THE FIRST CREDENTIAL THE REQUEST
// PRESENTS, IN CARRIER ORDER, IS THE ONE THAT ANSWERS. The IAM cookie is reached by
// a browser holding nothing else, which is exactly the post-cutover client and
// nobody today — which is what makes this phase inert. Within a carrier IAM wins: a
// header that verifies as IAM is never re-read as HS256.
func (id *identity) who(c *zip.Ctx) (caller, error) {
if id == nil {
return caller{}, fmt.Errorf("no identity seam")
}
ctx := c.Context()
if raw := bearer(c); raw != "" {
return id.verified(ctx, raw)
}
if raw := c.Fiber().Req().Cookies(authCookie); raw != "" {
return id.hs256(raw)
}
return id.iam(ctx, c.Fiber().Req().Cookies(iamTokenCookie))
}
// verified is who() over ONE presented credential rather than over a request's
// carriers — the same two lanes in the same order, for the surfaces that carry the
// credential in a body or a path segment instead of a header. The HS256 error is
// the one reported: both arms fail closed, so the caller learns why the credential
// it actually holds was refused rather than why the other lane did not claim it.
func (id *identity) verified(ctx context.Context, raw string) (caller, error) {
if cl, err := id.iam(ctx, raw); err == nil {
return cl, nil
}
return id.hs256(raw)
}
// iam turns a VERIFIED IAM ACCESS token into a caller. Fails closed on every
// path: no validator, no store to resolve against, an unverifiable token, one that
// is not an access token, one whose owner claim is empty (there is no tenant to
// scope to), and one whose SUBJECT names no account in that tenant.
//
// THE SUBJECT, NEVER THE CANONICAL USER ID. VerifiedIdentity.User falls back sub →
// preferred_username → name, so a token carrying no sub presents its USERNAME
// there — and accountID returns a UUID-shaped input verbatim, so a username set to
// a colleague's account uuid resolved to the colleague, and admit() then granted
// every workspace the two share. Subject-only closes it; the account itself comes
// from the store (AccountForSubject), which confirms the row a login created
// rather than asserting an id no row has to match.
//
// TYPE, NOT JUST SIGNATURE. IAM's signer emits the same claim set into the access
// token and the id_token but for aud/tokenType/nonce (middleware_identity.go), so
// a valid signature from a trusted issuer does not say WHICH of them arrived — and
// the id_token is the one handed to a browser SPA to read. A session credential
// must be the access token, so the type is checked here.
//
// AUDIENCE IS CHECKED HERE, and it is checked here BECAUSE the identity boundary
// deliberately does not. That posture was decided for the boundary, whose job is
// "did IAM mint this for one of its own apps" — for an API call, aud only names
// which app, and cloud kept no mirror of IAM's registry because the mirror drifted
// and silently 401'd every new first-party app. A SESSION is a different question.
// This lane turns a bearer into a signed-in person on hanzo.team, and a token the
// user obtained for a DIFFERENT app — chat, the console, any OIDC client they ever
// clicked through — is not consent to that. Without the gate, one app's token is
// every app's session, which is the confused-deputy shape the estate closes
// elsewhere by narrowing at the resource server rather than at the door.
//
// The set is this deployment's OWN client id and nothing else by default, so it
// cannot drift into a registry mirror: it is one value team already has to know to
// run its OAuth flow, and the browser's hanzo_iam_token is the token that flow
// exchanged, so it carries exactly this audience. Additional first-party SPAs are
// named explicitly by an operator (TEAM_IAM_AUDIENCES) rather than admitted by a
// pattern — an audience allowlist that grows by rule is the mirror again.
//
// TENANT IS THE HOME ORG, NEVER `owner`. v.Owner carries the APPLICATION's org, so
// it is chosen by whichever app the caller authenticated through; a token with
// owner="lux" and a membership set naming hanzo would otherwise scope every team
// store query to "lux". The boundary refuses to derive a tenant from that claim
// (idClaims.homeOrg) and so does this. An empty home is a refusal, which also
// excludes every MACHINE credential — a client_credentials app or an API key is a
// member of nothing, and a team session is a person's.
func (id *identity) iam(ctx context.Context, raw string) (caller, error) {
if id == nil || id.verify == nil {
return caller{}, fmt.Errorf("no iam validator")
}
if id.accounts == nil {
return caller{}, fmt.Errorf("no account store to resolve a subject against")
}
if raw == "" {
return caller{}, fmt.Errorf("no token")
}
v, err := id.verify(raw)
if err != nil {
return caller{}, err
}
if !isAccessToken(v.TokenType) {
return caller{}, fmt.Errorf("not an access token: tokenType %q", v.TokenType)
}
if !id.forThisDeployment(v.Audience) {
return caller{}, fmt.Errorf("token audience %v is not a team session audience", v.Audience)
}
org := v.Home()
if org == "" {
return caller{}, fmt.Errorf("verified token names no home org")
}
if v.Subject == "" {
return caller{}, fmt.Errorf("verified token carries no subject")
}
account, ok := id.accounts.AccountForSubject(ctx, org, v.Subject)
if !ok {
return caller{}, fmt.Errorf("verified subject holds no account in %q", org)
}
user := ""
if v.Username != "" {
user = org + "/" + v.Username
}
// NO raw: an IAM credential never leaves this function. See caller.raw.
return caller{
account: account,
org: org,
orgs: homeOrgs(v.Orgs, org),
user: user,
iam: true,
}, nil
}
// forThisDeployment reports whether a token was minted for an app whose session
// this deployment is. Empty audience is REFUSED: a session credential that names
// no app is one nobody consented to hand here.
//
// THE INCOMING CLAIM IS MATCHED EXACTLY — no trim, no fold, no normalisation.
// Normalising it here would make the comparison non-injective: "hanzo-team " and
// "hanzo-team" are DISTINCT IAM applications (IAM refuses only an exact name
// collision, so the padded one is registrable), and trimming collapses them onto
// one key, handing every session of the real app to whoever registered the
// lookalike. This is the rule OrgHasUnsafeRune states for orgs — an injective
// boundary must never fold two distinct identifiers into one — applied to the
// identifier this door happens to compare.
//
// Whitespace is dealt with once, on the way IN, where the set is BUILT
// (sessionAudience): an operator's config entry is theirs to tidy, a signed claim
// is not ours to rewrite.
func (id *identity) forThisDeployment(aud []string) bool {
for _, a := range aud {
if id.audience[a] {
return true
}
}
return false
}
// sessionAudience is the set of IAM apps whose access tokens this deployment
// accepts as a team session: its OWN client id, plus any explicitly named by the
// operator in TEAM_IAM_AUDIENCES (comma-separated).
//
// The default is one value — the client id team already needs to run its OAuth
// flow, and therefore the audience of the very token that flow puts in the
// browser's cookie. Extra entries are NAMED, never matched by a pattern: an
// audience set that grows by rule is the IAM app-registry mirror the estate
// deleted, arriving one wildcard at a time.
//
// Trimming happens HERE and only here — an operator's config entry is theirs to
// tidy, while the signed claim this set is compared against is matched exactly
// (see identity.forThisDeployment for why folding it is a hole).
//
// Phase-2 precondition: if the hanzo-team IAM app is IsShared, seed
// clientID+"-org-"+<org> here — a shared app's access tokens carry the per-org
// audience form.
func sessionAudience(cfg config) map[string]bool {
out := map[string]bool{}
if id := strings.TrimSpace(cfg.iamClientID); id != "" {
out[id] = true
}
for _, a := range strings.Split(os.Getenv("TEAM_IAM_AUDIENCES"), ",") {
if a = strings.TrimSpace(a); a != "" {
out[a] = true
}
}
return out
}
// isAccessToken reports whether IAM's `tokenType` names the token a bearer session
// may be built on.
//
// The comparison is case-insensitive and treats an ABSENT type as an access token,
// which is the one permissive branch here and is deliberate: IAM has minted tokens
// without the claim, and refusing those would sign every one of those users out at
// deploy rather than at expiry. It is safe in the direction that matters — the
// id_token this exists to exclude is exactly the one that DOES carry a type, so an
// omitted claim is never an id_token being waved through. It stops being reached as
// tokens roll over, rather than needing a flag day.
func isAccessToken(t string) bool {
switch strings.ToLower(strings.TrimSpace(t)) {
case "", "access-token", "access_token", "bearer":
return true
default:
return false
}
}
// hs256 decodes AND verifies (signature + expiry) the HS256 session or workspace
// token this service minted. The tenant, the membership set and the workspace all
// come from its SIGNED claims.
func (id *identity) hs256(raw string) (caller, error) {
if id == nil {
return caller{}, fmt.Errorf("no identity seam")
}
if raw == "" {
return caller{}, fmt.Errorf("no token")
}
t, err := token.Decode(raw, id.secret, true)
if err != nil {
return caller{}, err
}
if t.Account == "" {
return caller{}, fmt.Errorf("token has no account")
}
user, _ := t.Extra["user"].(string)
return caller{
account: t.Account,
org: t.Org(),
orgs: orgsFromExtra(t.Extra),
user: user,
workspace: t.Workspace,
raw: raw,
}, nil
}
// admit authorizes cl for the workspace the REQUEST named and returns its row.
// Membership IS the authorization — the server reads the rows, the caller signs
// nothing — which is why it is the one gate both lanes pass through wherever a
// workspace is named. Every failure answers the same errNoWorkspace, so an unknown
// workspace, another tenant's, and one the caller is not in are indistinguishable.
func (id *identity) admit(ctx context.Context, cl caller, wsUUID string) (workspace, error) {
if id == nil || id.accounts == nil {
return workspace{}, errNoWorkspace
}
// A caller with no tenant, or none with an account, names nothing to be a member
// of — and an empty org is a value the owner_org scoping would happily match a
// row against. Refused here, once, so every surface inherits the same floor.
if cl.org == "" || cl.account == "" {
return workspace{}, errNoWorkspace
}
w, err := id.accounts.WorkspaceByUUID(ctx, cl.org, strings.TrimSpace(wsUUID))
if err != nil {
return workspace{}, errNoWorkspace
}
if _, ok := id.accounts.Membership(ctx, w.ID, cl.account); !ok {
return workspace{}, errNoWorkspace
}
return w, nil
}
// ── helpers ───────────────────────────────────────────────────────────────────
// account resolves (AccountUuid, org, token) from the request's verified caller.
// The org is the IAM tenant — the HOME org of a verified access token, or the HS256
// token's SIGNED extra.org claim — and is the key for every account-store query,
// never a client header.
//
// The token is EMPTY for an IAM caller, and that is the answer rather than a gap:
// it is echoed to the SPA as its session token, and an IAM caller's credential is
// an estate-wide bearer held in an HttpOnly cookie that script must never see (see
// caller.raw). Such a caller already holds the credential it authenticated with, so
// there is nothing it needs handed back.
func (g *api) account(c *zip.Ctx) (account, org, tok string, err error) {
t, raw, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return "", "", "", err
}
org = t.Org()
return t.Account, org, raw, nil
return cl.account, cl.org, cl.raw, nil
}
// accountOrgs resolves (AccountUuid, membership set, token) from the verified
// session token. The set is the SIGNED extra.orgs claim (home + every team org),
// read back home-safe by orgsFromExtra — the tenant SET the cross-org surfaces
// caller. The set is home-safe — the verified `orgs` claim on the IAM lane, the
// SIGNED extra.orgs on the HS256 one — and is the tenant SET the cross-org surfaces
// (getUserWorkspaces union, selectWorkspace resolution) enumerate. Never a client
// header. Empty account fails closed, exactly like account().
func (g *api) accountOrgs(c *zip.Ctx) (account string, orgs []model.OrgRef, tok string, err error) {
t, raw, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return "", nil, "", err
}
return t.Account, orgsFromExtra(t.Extra), raw, nil
return cl.account, cl.orgs, cl.raw, nil
}
// callbackOrigin is the ORIGIN the OAuth redirect_uri is built from — the SAME
+37
View File
@@ -349,6 +349,43 @@ func (s *accountStore) Membership(ctx context.Context, workspaceID, account stri
return role, true
}
// AccountForSubject is the ONE answer to "which team account is this IAM
// identity?", and the store is deliberately the one that gives it.
//
// The subject is the `sub` claim VERBATIM and nothing else. It is NOT the
// canonical user id: that one falls back sub → preferred_username → name, so a
// token carrying no sub presents its USERNAME there — and accountID returns a
// UUID-shaped input verbatim, so a username that is a colleague's account uuid
// would have resolved to the colleague. Subject-only closes that, and an empty
// subject is refused exactly as the OAuth callback's userinfo() refuses one.
//
// It then CONFIRMS the derived id against the rows instead of asserting it. The
// derivation (accountID) is the same function establishSession stores the rows
// under — one derivation, not two — but a login is what CREATES those rows, so an
// id that matches none is an identity this deployment has never seen, and the
// honest answer is "no account" rather than an account-shaped string every later
// query would then scope by. That is what makes the caller's refusal true rather
// than merely documented.
//
// The existence check is org-scoped: a member row is only this org's if its
// workspace is. So a subject known in org A resolves to nothing in org B, and the
// answer cannot be used to probe another tenant.
func (s *accountStore) AccountForSubject(ctx context.Context, org, subject string) (string, bool) {
account := accountID(strings.TrimSpace(subject))
if account == "" || strings.TrimSpace(org) == "" {
return "", false
}
var found string
err := s.db.Select("m.user_id").From("members m").
InnerJoin("workspaces w", query.NewExp("w.id = m.workspace_id")).
Where(query.HashExp{"w.owner_org": org, "m.user_id": account}).
Limit(1).WithContext(ctx).Row(&found)
if err != nil || found == "" {
return "", false
}
return found, true
}
// MembersForWorkspaceUUID returns the member rows of a workspace, resolved by
// (org, workspace uuid) so a foreign tenant's uuid returns nothing. This is the
// human half of the roster reconcile.
+11 -12
View File
@@ -77,7 +77,7 @@ type billingService struct {
accounts *accountStore
commerce types.CommerceClient
planEnt func(context.Context, string) (map[string]any, error)
secret string
ident *identity
degraded bool
}
@@ -128,7 +128,7 @@ func (b *billingService) readPlan(ctx context.Context, _ *none) (*planInfo, erro
if b.degraded {
return nil, unavailable()
}
_, org, err := sessionOf(ctx, b.secret)
_, org, err := sessionOf(ctx, b.ident)
if err != nil {
return nil, zip.ErrUnauthorized("sign in to view billing")
}
@@ -161,7 +161,7 @@ func (b *billingService) readPlan(ctx context.Context, _ *none) (*planInfo, erro
// index.html (the SPA shell). Session-gated — an anonymous caller gets 401,
// never the page. Fingerprinted assets/ cache hard; the shell never caches.
func (b *billingService) ui(c *zip.Ctx) error {
if _, _, err := orgPrincipal(c, b.secret); err != nil {
if _, _, err := orgPrincipal(c, b.ident); err != nil {
return zip.ErrUnauthorized("sign in to view billing")
}
root := wallet.FS()
@@ -196,18 +196,17 @@ func walletContentType(name string) string {
return "application/octet-stream"
}
// orgPrincipal resolves (account, org) from the request's VERIFIED session or
// workspace token (bearer or the HttpOnly account cookie), refusing a token
// that carries no org — the ONE token→tenant resolution the files and billing
// planes share.
func orgPrincipal(c *zip.Ctx, secret string) (account, org string, err error) {
t, _, err := sessionToken(c, secret)
// orgPrincipal resolves (account, org) from the request's VERIFIED caller
// (identity.who — an IAM access token, else team's own HS256 token, on a header or
// a cookie), refusing one that carries no org — the ONE credential→tenant
// resolution the files and billing planes share.
func orgPrincipal(c *zip.Ctx, id *identity) (account, org string, err error) {
cl, err := id.who(c)
if err != nil {
return "", "", err
}
org = t.Org()
if org == "" {
if cl.org == "" {
return "", "", errNoOrg
}
return t.Account, org, nil
return cl.account, cl.org, nil
}
+1 -1
View File
@@ -33,7 +33,7 @@ func billingApp(t *testing.T, commerce types.CommerceClient, planEnt func(contex
t.Fatalf("openAccountStore: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
b := &billingService{accounts: store, commerce: commerce, planEnt: planEnt, secret: testSecret}
b := &billingService{accounts: store, commerce: commerce, planEnt: planEnt, ident: testIdent(store)}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// The SAME bridge the composer installs at its root. The plan read is a typed
// op, and a typed op receives only a context — the request its session token
+826
View File
@@ -0,0 +1,826 @@
package team
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/internal/iamtest"
"github.com/hanzoai/cloud/plane"
)
// The IAM subject the fake validator answers for, and the account it must resolve
// to. accountID is the join establishSession stores rows under, so a lane that
// resolved anything else would address an account that flow never created.
const (
iamSub = "11111111-2222-4333-8444-555555555555"
iamOtherSub = "99999999-2222-4333-8444-555555555555"
)
// openTestStore opens an isolated account store for one test.
func openTestStore(t *testing.T) *accountStore {
t.Helper()
s, err := openAccountStore(t.TempDir())
if err != nil {
t.Fatalf("openAccountStore: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
// testIdent is the seam a test drives, with NO IAM validator: the HS256 arm alone,
// which is what the services that never see an IAM token are built with.
func testIdent(accounts *accountStore) *identity {
return &identity{secret: testSecret, accounts: accounts}
}
// identFor is the seam with both lanes live, verifying against a REAL issuer: the
// tokens are signed, the JWKS is fetched, and the claim mapping under test is
// cloud's own. Nothing between a signed token and a caller is faked.
func identFor(t *testing.T, accounts *accountStore) (*identity, *iamtest.Issuer0) {
t.Helper()
iam := iamtest.New(t)
verify := cloud.NewTokenValidator(iamtest.Issuer).Validate
return &identity{
verify: verify, secret: testSecret, accounts: accounts,
audience: map[string]bool{iamtest.Audience: true},
}, iam
}
// orgsOf is the signed membership set naming org as HOME — the first entry, which
// is the tenant rule the estate states once in idClaims.homeOrg.
func orgsOf(org string) []map[string]any {
return []map[string]any{{"org": org, "role": "admin"}}
}
// homeIn is the ordinary token: this subject, at home in this org.
func homeIn(org, sub string) iamtest.Claims {
return iamtest.Claims{Sub: sub, Owner: org, Orgs: orgsOf(org)}
}
// enrolled creates the account rows a login creates, and returns the account id
// the store will answer for that subject. The IAM lane resolves an account only
// when a login already made one — so a test about resolution has to enrol first.
func enrolled(t *testing.T, store *accountStore, org, subject, name string) string {
t.Helper()
if _, err := store.EnsureWorkspace(context.Background(), org, accountID(subject), name); err != nil {
t.Fatalf("enrol %s in %s: %v", subject, org, err)
}
return accountID(subject)
}
// withReq drives one request carrying whichever credentials the case is about and
// runs fn against its live context. The context is pooled and recycled the moment
// the handler returns, so the assertion has to happen inside it.
func withReq(t *testing.T, bearerTok, iamCookie, acctCookie string, fn func(*zip.Ctx)) {
t.Helper()
app := zip.New(zip.Config{})
ran := false
app.Get("/probe", func(c *zip.Ctx) error {
ran = true
fn(c)
return c.String(http.StatusOK, "ok")
})
r := httptest.NewRequest(http.MethodGet, "/probe", nil)
if bearerTok != "" {
r.Header.Set("Authorization", "Bearer "+bearerTok)
}
if iamCookie != "" {
r.AddCookie(&http.Cookie{Name: iamTokenCookie, Value: iamCookie})
}
if acctCookie != "" {
r.AddCookie(&http.Cookie{Name: authCookie, Value: acctCookie})
}
if _, err := app.Test(r); err != nil {
t.Fatalf("probe: %v", err)
}
if !ran {
t.Fatal("probe handler never ran")
}
}
// whoOn resolves a caller off a request carrying those credentials.
func whoOn(t *testing.T, id *identity, bearerTok, iamCookie, acctCookie string) (cl caller, err error) {
t.Helper()
withReq(t, bearerTok, iamCookie, acctCookie, func(c *zip.Ctx) { cl, err = id.who(c) })
return cl, err
}
// hsToken mints an HS256 token exactly as this service does.
func hsToken(t *testing.T, account, workspace, org string) string {
t.Helper()
tok, err := token.Generate(account, workspace, map[string]any{"org": org}, expUnix(sessionTokenTTL), testSecret)
if err != nil {
t.Fatalf("token.Generate: %v", err)
}
return tok
}
// TestIAMLaneResolvesTheAccount proves the IAM lane addresses the account the
// OAuth callback's join creates, resolved through the STORE, with the tenant taken
// from the verified owner claim and NEVER from anything the caller wrote, on both
// carriers. The token is really signed and really verified.
func TestIAMLaneResolvesTheAccount(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
want := enrolled(t, store, "acme", iamSub, "Ada")
raw := iam.Sign(t, iamtest.Claims{
Sub: iamSub, PreferredUsername: "ada", Owner: "acme",
Orgs: []map[string]any{{"org": "acme", "role": "admin"}, {"org": "beta", "role": "member"}},
})
for name, carrier := range map[string][2]string{
"bearer": {raw, ""},
"cookie": {"", raw},
} {
cl, err := whoOn(t, id, carrier[0], carrier[1], "")
if err != nil {
t.Fatalf("%s: who: %v", name, err)
}
if !cl.iam {
t.Fatalf("%s: resolved on the HS256 arm, want the IAM lane", name)
}
if cl.account != want {
t.Fatalf("%s: account = %q, want the enrolled account %q", name, cl.account, want)
}
if cl.org != "acme" {
t.Fatalf("%s: org = %q, want the verified owner", name, cl.org)
}
if cl.user != "acme/ada" {
t.Fatalf("%s: user = %q, want <owner>/<name>", name, cl.user)
}
// The IAM lane pins NO workspace: what it may touch is decided per request
// against the rows, never by a claim it carries.
if cl.workspace != "" {
t.Fatalf("%s: workspace = %q, want the IAM lane to pin none", name, cl.workspace)
}
// Home-safe: the verified membership set plus the home tenant.
if len(cl.orgs) != 2 || cl.orgs[0].Org != "acme" || cl.orgs[1].Org != "beta" {
t.Fatalf("%s: orgs = %v, want [acme beta]", name, cl.orgs)
}
// An IAM credential NEVER leaves the seam: raw is empty on this lane, so no
// echo site can hand a platform bearer back to page JS.
if cl.raw != "" {
t.Fatalf("%s: caller.raw carries the IAM credential", name)
}
}
}
// TestIAMLaneKeysOnTheSubjectNotTheUsername is the F2 regression, and it is the
// reason these tests sign real tokens.
//
// VerifiedIdentity.User falls back sub → preferred_username → name, and accountID
// returns a UUID-shaped input VERBATIM. So a token with NO `sub` whose
// preferred_username is a colleague's account uuid used to resolve to that
// colleague — and admit() then granted every workspace the two share. Nothing
// about that token is forged: IAM signs it, the issuer is trusted, the signature
// verifies. Only the claim the lane READS decides who it is.
//
// The attacker needs a token IAM will mint with no sub and a chosen username, so
// this is a privilege escalation gated on an IAM-side condition rather than an open
// door — which is exactly the kind that survives review by being called impossible.
func TestIAMLaneKeysOnTheSubjectNotTheUsername(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
victim := enrolled(t, store, "acme", iamSub, "Ada")
// The victim's ACCOUNT UUID worn as a username, on a token carrying no subject.
attack := iam.Sign(t, iamtest.Claims{PreferredUsername: victim, Name: victim, Owner: "acme", Orgs: orgsOf("acme")})
// The canonical user id really does resolve to the victim — the fallback fires,
// so this test observes the actual hazard rather than assuming it away.
v, err := id.verify(attack)
if err != nil {
t.Fatalf("the attack token did not verify, so this test proves nothing: %v", err)
}
if v.User != victim {
t.Fatalf("setup: User = %q, want the fallback to resolve it to the victim %q", v.User, victim)
}
if v.Subject != "" {
t.Fatalf("setup: Subject = %q, want no subject on this token", v.Subject)
}
// And the lane refuses it outright rather than resolving it to that account.
cl, err := id.iam(context.Background(), attack)
if err == nil {
t.Fatalf("SECURITY: a token with no subject resolved to account %q — the victim's", cl.account)
}
if _, err := whoOn(t, id, attack, "", ""); err == nil {
t.Fatal("SECURITY: the seam admitted a subject-less token")
}
// The same token, now WITH its own subject, is a different person entirely and
// resolves to no account here — so the refusal above is about the missing
// subject, not about the token being unusable in general.
own := iam.Sign(t, iamtest.Claims{Sub: iamOtherSub, PreferredUsername: victim, Owner: "acme", Orgs: orgsOf("acme")})
if _, err := id.iam(context.Background(), own); err == nil {
t.Fatal("SECURITY: a subject with no account row resolved to one anyway")
}
// And the victim's own token still works, so the gate discriminates.
good := iam.Sign(t, homeIn("acme", iamSub))
cl, err = id.iam(context.Background(), good)
if err != nil || cl.account != victim {
t.Fatalf("the victim's own token resolved (%+v, %v)", cl, err)
}
}
// TestIAMLaneRefusesAnIDToken is the F4 regression. IAM's signer emits the same
// claim set into the access token and the id_token but for aud/tokenType/nonce, so
// signature and issuer cannot tell them apart — and the id_token is the one handed
// to a browser SPA to read. A session credential must be the access token.
func TestIAMLaneRefusesAnIDToken(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
idToken := iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme", Orgs: orgsOf("acme"), TokenType: "id-token"})
if _, err := id.iam(context.Background(), idToken); err == nil {
t.Fatal("SECURITY: an id_token was accepted as a team session credential")
}
if _, err := whoOn(t, id, idToken, "", ""); err == nil {
t.Fatal("SECURITY: the seam admitted an id_token")
}
// An access token is admitted, and so is a token minted before IAM emitted the
// claim at all — the one permissive branch, which must not sign existing users
// out at deploy.
for _, tt := range []string{"access-token", "-"} {
if _, err := id.iam(context.Background(), iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme", Orgs: orgsOf("acme"), TokenType: tt})); err != nil {
t.Fatalf("tokenType %q was refused: %v", tt, err)
}
}
}
// TestIAMLaneRefusesWhatDoesNotVerify proves every failure of the IAM lane is a
// refusal, not a downgrade to a partially-trusted caller: a forged signature, an
// expired token, a verified one with no tenant to scope to, and one naming no
// subject. Each is a real signed token, so each failure is the real code path.
func TestIAMLaneRefusesWhatDoesNotVerify(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
// A DIFFERENT issuer, publishing a key this validator never fetches. It reuses
// the same kid on purpose: the token names a key the validator does have, and
// still fails, so the refusal is the signature check and not a missing key.
other := iamtest.New(t)
bad := map[string]string{
"forged": other.Sign(t, homeIn("acme", iamSub)),
"expired": iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme", Orgs: orgsOf("acme"), Exp: time.Now().Add(-time.Hour)}),
"no home org": iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme"}),
"no subject": iam.Sign(t, iamtest.Claims{Owner: "acme", Orgs: orgsOf("acme")}),
"garbage": "not.a.token",
}
for name, raw := range bad {
if _, err := id.iam(context.Background(), raw); err == nil {
t.Fatalf("iam(%s) admitted a caller it must refuse", name)
}
// And through the whole seam, with no HS256 credential to fall back to.
if _, err := whoOn(t, id, raw, "", ""); err == nil {
t.Fatalf("who(bearer=%s) admitted a caller it must refuse", name)
}
}
if _, err := whoOn(t, id, iam.Sign(t, homeIn("acme", iamSub)), "", ""); err != nil {
t.Fatalf("who(valid IAM bearer): %v", err)
}
}
// TestIAMLaneWorkspaceIsMembership proves the IAM lane grants a workspace ONLY
// from the rows: a member is admitted, a non-member and a foreign tenant's
// workspace are refused, and the two refusals are indistinguishable.
func TestIAMLaneWorkspaceIsMembership(t *testing.T) {
store := openTestStore(t)
ctx := context.Background()
member := accountID(iamSub)
stranger := accountID(iamOtherSub)
ws, err := store.EnsureWorkspace(ctx, "acme", member, "Ada")
if err != nil {
t.Fatal(err)
}
// A workspace in ANOTHER tenant, which the caller is a member of THERE.
other, err := store.EnsureWorkspace(ctx, "rival", member, "Ada")
if err != nil {
t.Fatal(err)
}
id, iam := identFor(t, store)
// The stranger holds an account in the SAME org (they logged in) but no row in
// this workspace — the case membership has to answer, not existence.
if _, err := store.EnsureWorkspace(ctx, "acme", stranger, "Bob"); err != nil {
t.Fatal(err)
}
memberCl, err := id.iam(ctx, iam.Sign(t, homeIn("acme", iamSub)))
if err != nil {
t.Fatal(err)
}
strangerCl, err := id.iam(ctx, iam.Sign(t, homeIn("acme", iamOtherSub)))
if err != nil {
t.Fatal(err)
}
if _, err := id.admit(ctx, memberCl, ws.UUID); err != nil {
t.Fatalf("admit(member, own workspace): %v", err)
}
if _, err := id.admit(ctx, strangerCl, ws.UUID); err == nil {
t.Fatalf("admit(non-member) was granted — membership is the authorization")
}
// Same person, same account row, a workspace they ARE a member of — but their
// token names tenant acme, so the rival-owned workspace is not theirs to open
// on this credential.
if _, err := id.admit(ctx, memberCl, other.UUID); err == nil {
t.Fatalf("admit crossed the tenant boundary into a foreign org's workspace")
}
if _, err := id.admit(ctx, memberCl, uuid.NewString()); err == nil {
t.Fatalf("admit granted a workspace that does not exist")
}
// A caller with no tenant names nothing to be a member of.
if _, err := id.admit(ctx, caller{account: member}, ws.UUID); err == nil {
t.Fatalf("admit granted a caller carrying no org")
}
if _, err := id.admit(ctx, caller{org: "acme"}, ws.UUID); err == nil {
t.Fatalf("admit granted a caller carrying no account")
}
if stranger == member {
t.Fatal("test setup: the two subjects must resolve to different accounts")
}
}
// TestHS256ArmIsUnchanged proves the fallback arm answers exactly what the
// pre-cutover decode answered, for the same fixtures, on both carriers and in the
// same precedence: bearer before cookie, an account claim required, expiry
// enforced, and the tenant + workspace read from the SIGNED claims.
func TestHS256ArmIsUnchanged(t *testing.T) {
const acct = "550e8400-e29b-41d4-a716-446655440000"
wsUUID := uuid.NewString()
session := hsToken(t, acct, "", "acme")
workspace := hsToken(t, acct, wsUUID, "acme")
id := testIdent(nil)
// Bearer.
cl, err := whoOn(t, id, workspace, "", "")
if err != nil {
t.Fatalf("who(hs256 bearer): %v", err)
}
if cl.iam {
t.Fatal("an HS256 token resolved on the IAM lane")
}
if cl.account != acct || cl.org != "acme" || cl.workspace != wsUUID || cl.raw != workspace {
t.Fatalf("hs256 bearer = %+v", cl)
}
// Cookie, and the bearer still wins over it — the pre-cutover precedence.
cl, err = whoOn(t, id, workspace, "", session)
if err != nil {
t.Fatalf("who(bearer + account cookie): %v", err)
}
if cl.workspace != wsUUID {
t.Fatal("the account cookie displaced the bearer")
}
cl, err = whoOn(t, id, "", "", session)
if err != nil {
t.Fatalf("who(account cookie): %v", err)
}
if cl.account != acct || cl.workspace != "" {
t.Fatalf("hs256 cookie = %+v", cl)
}
// No credential, a forged one, and one carrying no account are all refused.
if _, err := whoOn(t, id, "", "", ""); err == nil {
t.Fatal("who admitted a request carrying no credential")
}
if _, err := whoOn(t, id, session+"x", "", ""); err == nil {
t.Fatal("who admitted a token whose signature does not check out")
}
expired, err := token.Generate(acct, "", map[string]any{"org": "acme"}, 1, testSecret)
if err != nil {
t.Fatal(err)
}
if _, err := whoOn(t, id, expired, "", ""); err == nil {
t.Fatal("who admitted an expired token")
}
}
// TestLanePrecedence pins the rule the whole cutover rests on: THE EXISTING
// CREDENTIAL IS ANSWERED FIRST, on both carriers, so this phase changes nothing
// for a client that has one.
//
// The two credentials are NOT interchangeable — an HS256 workspace token pins a
// workspace and an IAM token cannot — so preferring the IAM cookie did not merely
// pick a different lane, it silently WIDENED the collaborator planes from "the
// workspace this token names" to "any workspace you are a member of", and made
// getWorkspaceInfo answer WorkspaceNotFound where the pin used to answer. A phase
// that is supposed to be inert cannot do that, so the order is: bearer alone if
// there is a bearer; then account-token; then hanzo_iam_token for the browser that
// holds nothing else, which is exactly the post-cutover client.
func TestLanePrecedence(t *testing.T) {
const acct = "550e8400-e29b-41d4-a716-446655440000"
wsUUID := uuid.NewString()
hs := hsToken(t, acct, wsUUID, "acme")
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "iam-org", iamSub, "Ada")
iamTok := iam.Sign(t, homeIn("iam-org", iamSub))
// BOTH cookies — the state every signed-in browser is in today. The HS256 one
// wins, and it keeps its workspace pin.
cl, err := whoOn(t, id, "", iamTok, hs)
if err != nil {
t.Fatalf("who(both cookies): %v", err)
}
if cl.iam {
t.Fatal("the IAM cookie displaced a live account-token cookie — the phase is not inert")
}
if cl.org != "acme" || cl.workspace != wsUUID {
t.Fatalf("both cookies resolved %+v, want the HS256 caller with its workspace pin", cl)
}
// A live HS256 bearer beside an IAM cookie stays on the HS256 arm too.
cl, err = whoOn(t, id, hs, iamTok, "")
if err != nil {
t.Fatalf("who(hs256 bearer + iam cookie): %v", err)
}
if cl.iam || cl.org != "acme" || cl.workspace != wsUUID {
t.Fatalf("hs256 bearer resolved %+v", cl)
}
// The IAM cookie alone — the post-cutover browser — resolves on the IAM lane.
cl, err = whoOn(t, id, "", iamTok, "")
if err != nil {
t.Fatalf("who(iam cookie only): %v", err)
}
if !cl.iam || cl.org != "iam-org" {
t.Fatalf("iam cookie alone resolved %+v, want the IAM lane", cl)
}
// An IAM bearer wins over the HS256 arm on the SAME carrier: a header that
// verifies as IAM is never re-read as HS256.
cl, err = whoOn(t, id, iamTok, "", hs)
if err != nil {
t.Fatalf("who(iam bearer): %v", err)
}
if !cl.iam {
t.Fatal("an IAM bearer was not read as IAM")
}
// A stale IAM cookie beside a live account-token is simply never reached.
cl, err = whoOn(t, id, "", "stale.iam.cookie", hs)
if err != nil {
t.Fatalf("who(stale iam cookie + account cookie): %v", err)
}
if cl.iam || cl.account != acct {
t.Fatalf("stale IAM cookie did not fall through: %+v", cl)
}
// And the converse: a STALE account-token answers alone rather than falling
// through to a live IAM cookie. Falling through would extend a session that used
// to end in a 401, with a different reach — the widening this order exists to
// prevent, arriving on a timer instead of on a deploy.
stale, err := token.Generate(acct, wsUUID, map[string]any{"org": "acme"}, 1, testSecret)
if err != nil {
t.Fatal(err)
}
if _, err := whoOn(t, id, "", iamTok, stale); err == nil {
t.Fatal("an expired account-token fell through to the IAM cookie — a session silently continued")
}
}
// TestIAMCredentialNeverReachesTheWire is the C1 regression.
//
// getLoginInfoByToken echoes the caller's token back to the SPA as its session
// token, and the SPA is page JS. On the IAM lane that credential is the raw
// estate-wide RS256 bearer out of an HttpOnly cookie — HttpOnly precisely so script
// cannot read it. Echoing it hands it back to the script the flag exists to stop:
// one RPC with no bearer at all, and the caller's whole platform credential is in a
// variable. caller.raw is therefore empty on that lane structurally, so no echo
// site can reintroduce the leak by forgetting.
func TestIAMCredentialNeverReachesTheWire(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
iamTok := iam.Sign(t, homeIn("acme", iamSub))
for name, carrier := range map[string][2]string{
"bearer": {iamTok, ""},
"cookie": {"", iamTok},
} {
cl, err := whoOn(t, id, carrier[0], carrier[1], "")
if err != nil {
t.Fatalf("%s: who: %v", name, err)
}
if cl.raw != "" {
t.Fatalf("SECURITY (%s): caller.raw carries the IAM credential, which every echo site returns to page JS", name)
}
if strings.Contains(cl.raw, iamTok) {
t.Fatalf("SECURITY (%s): the IAM credential leaked into the caller", name)
}
}
// The HS256 arm still echoes its own token — that one IS the SPA's session
// token, and the SPA is the party that presented it.
hs := hsToken(t, "550e8400-e29b-41d4-a716-446655440000", "", "acme")
cl, err := whoOn(t, id, hs, "", "")
if err != nil || cl.raw != hs {
t.Fatalf("the HS256 arm stopped echoing its own token: (%q, %v)", cl.raw, err)
}
}
// TestIAMLaneTenantIsTheHomeOrgNotOwner is the F4 regression.
//
// `owner` carries the APPLICATION's org, so it is chosen by whichever app the
// caller authenticated through — the identity boundary refuses to derive a tenant
// from it for exactly that reason (idClaims.homeOrg). A lane that reads it scopes
// every store query to an org the caller SELECTED: sign in through an app owned by
// "lux" and team files your workspaces, blobs and billing under lux.
func TestIAMLaneTenantIsTheHomeOrgNotOwner(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
home := enrolled(t, store, "hanzo", iamSub, "Ada")
// owner names one org, the SIGNED membership set names another as home.
crossed := iam.Sign(t, iamtest.Claims{
Sub: iamSub, Owner: "lux",
Orgs: []map[string]any{{"org": "hanzo", "role": "admin"}},
})
cl, err := id.iam(context.Background(), crossed)
if err != nil {
t.Fatalf("a token whose owner differs from its home org was refused outright: %v", err)
}
if cl.org != "hanzo" {
t.Fatalf("SECURITY: tenant = %q, want the home org \"hanzo\" — `owner` is caller-selectable", cl.org)
}
if cl.account != home {
t.Fatalf("account = %q, want the home-org account %q", cl.account, home)
}
// A token with NO membership set has no home, and that is a refusal rather than
// a fallback to owner — which is also every MACHINE credential (a
// client_credentials app or an API key is a member of nothing).
machine := iam.Sign(t, iamtest.Claims{Sub: "svc/robot", Owner: "hanzo"})
if _, err := id.iam(context.Background(), machine); err == nil {
t.Fatal("SECURITY: a token carrying no membership set was given a tenant")
}
}
// TestIAMLaneRefusesAForeignAudience is the C2 regression.
//
// The identity boundary deliberately does not gate audience: for an API call, a
// valid signature from a trusted issuer already proves IAM minted the token for one
// of its own apps, and cloud kept no mirror of IAM's registry. A SESSION is a
// different question — this lane turns a bearer into a signed-in person on
// hanzo.team, and a token the user obtained for chat or the console is not consent
// to that. Without the gate, one app's token is every app's session.
func TestIAMLaneRefusesAForeignAudience(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
for _, aud := range []string{"hanzo-chat", "hanzo-console", "hanzo-cloud", ""} {
c := homeIn("acme", iamSub)
c.Aud = aud
if aud == "" {
c.Aud = " " // an audience that names no app
}
if _, err := id.iam(context.Background(), iam.Sign(t, c)); err == nil {
t.Fatalf("SECURITY: a token minted for %q was accepted as a team session", aud)
}
}
// Team's own audience is admitted, so the gate discriminates.
if _, err := id.iam(context.Background(), iam.Sign(t, homeIn("acme", iamSub))); err != nil {
t.Fatalf("team's own audience was refused: %v", err)
}
// And an operator-named additional SPA is admitted, because it was NAMED.
id.audience["hanzo-front"] = true
c := homeIn("acme", iamSub)
c.Aud = "hanzo-front"
if _, err := id.iam(context.Background(), iam.Sign(t, c)); err != nil {
t.Fatalf("an explicitly named audience was refused: %v", err)
}
}
// TestTransactorTakesNothingAmbient pins the decision that the transactor socket
// has NO IAM lane in this phase.
//
// A WebSocket is exempt from CORS, so a cookie-borne credential would make the
// Origin header the only access control on the entire workspace data plane — one
// permissive entry in that allowlist, or one first-party page running attacker
// script, and the stream is readable and writable. The credential therefore stays
// the path-borne workspace token, which a foreign page cannot produce, until the
// client can send it in-band the way collabws.go already does.
func TestTransactorTakesNothingAmbient(t *testing.T) {
store := openTestStore(t)
ctx := context.Background()
member := accountID(iamSub)
ws, err := store.EnsureWorkspace(ctx, "acme", member, "Ada")
if err != nil {
t.Fatal(err)
}
id, iam := identFor(t, store)
srv := &transServer{ident: id}
// The workspace token is the credential, as it has always been.
wsTok := hsToken(t, member, ws.UUID, "acme")
cl, got, err := srv.admitWS(wsTok)
if err != nil || got != ws.UUID || cl.iam {
t.Fatalf("admitWS(workspace token) = (%+v, %q, %v)", cl, got, err)
}
// A bare workspace UUID authorizes NOTHING, whatever the caller holds elsewhere:
// it is not a credential, and there is no ambient lane to pair it with.
if _, _, err := srv.admitWS(ws.UUID); err == nil {
t.Fatal("SECURITY: a bare workspace uuid opened a socket")
}
// Nor does a valid IAM access token in that position — an estate-wide bearer
// does not belong in a URL, so it is simply not a workspace token.
if _, _, err := srv.admitWS(iam.Sign(t, homeIn("acme", iamSub))); err == nil {
t.Fatal("SECURITY: an IAM access token was accepted as a workspace token")
}
// A session token (no workspace claim) resolves but names no workspace, which is
// what lets the statistics read answer it with an empty session map while
// serveWS refuses it.
if _, got, err := srv.admitWS(hsToken(t, member, "", "acme")); err != nil || got != "" {
t.Fatalf("admitWS(session token) = (%q, %v)", got, err)
}
}
// TestOriginAllowlistHasNoWildcard pins the socket's Origin gate as a NAMED set.
//
// It used to admit any *.hanzo.ai host. Because a WebSocket is exempt from CORS,
// that check is the access control rather than a hint about it, so a wildcard over
// the registrable domain put every first-party host inside the workspace data
// plane's trust boundary.
func TestOriginAllowlistHasNoWildcard(t *testing.T) {
const host = "api.hanzo.ai"
for _, origin := range []string{
"https://chat.hanzo.ai", "https://preview.hanzo.ai", "https://anything.hanzo.ai",
"https://evil.com", "https://hanzo.ai.evil.com", "https://team.hanzo.ai.evil.com",
} {
if originAllowed(origin, host) {
t.Errorf("SECURITY: origin %q was admitted to the workspace socket", origin)
}
}
// The named team surfaces, the request's own host, and a non-browser client
// (which sends no Origin, and which a browser cannot imitate) still pass.
for _, origin := range []string{
"", "https://hanzo.team", "https://team.hanzo.ai", "https://api.hanzo.team",
"https://hanzo.ai", "http://localhost:3000", "https://" + host,
} {
if !originAllowed(origin, host) {
t.Errorf("origin %q was refused; the gate is not discriminating", origin)
}
}
}
// TestMemberPlaneOpScopesToTheCaller proves the membership answer a PEER process
// gets is scoped to the org on the CALL and to nothing the caller wrote: the same
// (workspace, subject) pair answers "member" for the owning tenant and "not a
// member" for any other, so a peer cannot probe a foreign roster one workspace at
// a time. It also proves the IAM subject → account join stays here, where the rows
// were created: the peer sends a subject and is told the account.
func TestMemberPlaneOpScopesToTheCaller(t *testing.T) {
store := openTestStore(t)
ctx := context.Background()
ws, err := store.EnsureWorkspace(ctx, "acme", accountID(iamSub), "Ada")
if err != nil {
t.Fatal(err)
}
in := &plane.MemberIn{Workspace: ws.UUID, Subject: iamSub}
got, err := memberOf(cloud.For(ctx, "acme"), store, in)
if err != nil {
t.Fatalf("memberOf(own org): %v", err)
}
if !got.Member || got.Role == "" {
t.Fatalf("memberOf(own org) = %+v, want a member with a role", got)
}
if got.Account != accountID(iamSub) {
t.Fatalf("account = %q, want accountID(subject) = %q", got.Account, accountID(iamSub))
}
// Another tenant asking about the SAME workspace uuid learns nothing.
got, err = memberOf(cloud.For(ctx, "rival"), store, in)
if err != nil {
t.Fatalf("memberOf(foreign org): %v", err)
}
if got.Member || got.Role != "" || got.Account != "" {
t.Fatalf("memberOf(foreign org) = %+v, want an empty answer", got)
}
// A stranger in the owning org is not a member either.
got, err = memberOf(cloud.For(ctx, "acme"), store, &plane.MemberIn{Workspace: ws.UUID, Subject: iamOtherSub})
if err != nil {
t.Fatalf("memberOf(stranger): %v", err)
}
if got.Member {
t.Fatal("memberOf admitted a subject with no row")
}
// A call carrying NO org is refused, not answered — a refusal is a fault an
// operator sees, a false negative is a join that silently stops working.
if _, err := memberOf(ctx, store, in); err == nil {
t.Fatal("memberOf answered a call carrying no org")
}
// And a store this process does not have open fails closed.
if _, err := memberOf(cloud.For(ctx, "acme"), nil, in); err == nil {
t.Fatal("memberOf answered with no store open")
}
}
// TestSessionAudienceIsNamedNotPatterned pins the SHAPE of the audience policy,
// which is the half a behavioural test cannot hold.
//
// The estate's boundary deliberately does not gate audience, and that posture was
// decided for an API door: a valid signature from a trusted issuer proves IAM
// minted the token for one of its own apps, and cloud kept no mirror of IAM's
// registry because the mirror drifted and 401'd every new first-party app. Team is
// a SESSION door and diverges — but the way that divergence rots is by growing back
// into the mirror, one pattern at a time ("any *-team app", "anything from our
// org"). So the set is enumerated: this deployment's own client id, plus entries an
// operator NAMED, and matching is exact.
func TestSessionAudienceIsNamedNotPatterned(t *testing.T) {
aud := sessionAudience(config{iamClientID: "hanzo-team"})
if len(aud) != 1 || !aud["hanzo-team"] {
t.Fatalf("default audience = %v, want exactly this deployment's own client id", aud)
}
t.Setenv("TEAM_IAM_AUDIENCES", "hanzo-front, hanzo-desktop ,,")
aud = sessionAudience(config{iamClientID: "hanzo-team"})
for _, want := range []string{"hanzo-team", "hanzo-front", "hanzo-desktop"} {
if !aud[want] {
t.Fatalf("audience %v is missing the named entry %q", aud, want)
}
}
if len(aud) != 3 {
t.Fatalf("audience = %v, want exactly the three named entries", aud)
}
// Matching is EXACT. Nothing here may admit an audience by resemblance — a
// prefix, a suffix, or a wildcard — because that is the registry mirror
// returning under another name.
id := &identity{audience: aud}
for _, foreign := range []string{
"hanzo-teamx", "xhanzo-team", "hanzo", "hanzo-team-staging",
"*", "", " ", "HANZO-TEAM",
} {
if id.forThisDeployment([]string{foreign}) {
t.Errorf("SECURITY: audience %q was admitted by resemblance", foreign)
}
}
if !id.forThisDeployment([]string{"other", "hanzo-team"}) {
t.Fatal("a token naming several audiences, one of them ours, was refused")
}
// A deployment with NO audience configured admits nothing, rather than
// everything: an empty allowlist is a closed door.
empty := &identity{audience: sessionAudience(config{})}
if empty.forThisDeployment([]string{"hanzo-team"}) {
t.Fatal("SECURITY: an unconfigured audience set admitted a token")
}
}
// TestAudienceIsMatchedExactlyNotFolded is the NEW-1 regression.
//
// The audience gate used to TrimSpace the incoming `aud` claim before looking it
// up. That made the comparison non-injective: "hanzo-team " and "hanzo-team" are
// DISTINCT IAM applications — IAM refuses only an exact name collision, so the
// padded one is registrable by anyone through /v1/iam/add-application — and
// trimming collapses them onto one key. An attacker registers the lookalike, signs
// their own users in through it, and IAM hands them tokens this door accepts as
// sessions of the real app.
//
// It is the estate's identifier rule, which OrgHasUnsafeRune states for orgs:
// trimming would collapse "acme " onto "acme", and an injective boundary must
// never fold two distinct identifiers into one. The claim is signed, so it is not
// ours to rewrite; whitespace is settled where the SET is built instead.
func TestAudienceIsMatchedExactlyNotFolded(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
// Every registrable lookalike that a fold would admit.
for _, lookalike := range []string{
"hanzo-team ", " hanzo-team", " hanzo-team ", "hanzo-team\t", "\nhanzo-team",
} {
if id.forThisDeployment([]string{lookalike}) {
t.Errorf("SECURITY: audience %q folded onto the real app's identifier", lookalike)
}
c := homeIn("acme", iamSub)
c.Aud = lookalike
if _, err := id.iam(context.Background(), iam.Sign(t, c)); err == nil {
t.Errorf("SECURITY: a token minted for the lookalike app %q was accepted as a team session", lookalike)
}
}
// The real audience is still accepted, so the gate discriminates rather than
// refusing everything.
if !id.forThisDeployment([]string{iamtest.Audience}) {
t.Fatal("the deployment's own audience was refused; the test is not discriminating")
}
if _, err := id.iam(context.Background(), iam.Sign(t, homeIn("acme", iamSub))); err != nil {
t.Fatalf("the deployment's own audience was refused: %v", err)
}
// And the tidying still happens where the SET is built: an operator's padded
// config entry is theirs to normalise, and it admits the UNPADDED app — never
// the other way round.
t.Setenv("TEAM_IAM_AUDIENCES", " hanzo-front ")
built := sessionAudience(config{iamClientID: " hanzo-team "})
if !built["hanzo-team"] || !built["hanzo-front"] {
t.Fatalf("sessionAudience did not trim its own config entries: %v", built)
}
if built[" hanzo-team "] || built["hanzo-team "] {
t.Fatalf("sessionAudience kept a padded key, which a padded claim would then match: %v", built)
}
}
+8 -11
View File
@@ -69,7 +69,7 @@ const collabPrefix = "/collaborator"
type collabService struct {
vfs types.VFSClient
accounts *accountStore
secret string
ident *identity
hub *collabHub
degraded bool
}
@@ -211,11 +211,11 @@ func (s *collabService) rpc(ctx context.Context, in *collabRequest) (*collabResu
if s.degraded {
return nil, unavailable()
}
t, err := tokenOf(ctx, s.secret)
cl, err := callerOf(ctx, s.ident)
if err != nil {
return nil, zip.ErrUnauthorized("invalid session token")
}
org := t.Org()
org := cl.org
if org == "" {
return nil, zip.ErrUnauthorized("invalid session token")
}
@@ -223,19 +223,16 @@ func (s *collabService) rpc(ctx context.Context, in *collabRequest) (*collabResu
if err != nil {
return nil, zip.ErrBadRequest("malformed documentId")
}
// The workspace token names its workspace — the documentId must agree. A
// session token (no workspace claim) falls through to the membership check.
if t.Workspace != "" && t.Workspace != doc.workspace {
// An HS256 workspace token names its workspace — the documentId must agree. A
// credential that names none (a session token, and every IAM caller) falls
// through to the membership check, which is the whole authorization there.
if cl.workspace != "" && cl.workspace != doc.workspace {
return nil, zip.ErrNotFound("document not found")
}
if s.accounts == nil {
return nil, zip.Errorf(http.StatusServiceUnavailable, "team: collaborator unavailable")
}
w, err := s.accounts.WorkspaceByUUID(ctx, org, doc.workspace)
if err != nil {
return nil, zip.ErrNotFound("document not found")
}
if _, ok := s.accounts.Membership(ctx, w.ID, t.Account); !ok {
if _, err := s.ident.admit(ctx, cl, doc.workspace); err != nil {
return nil, zip.ErrNotFound("document not found")
}
+10 -13
View File
@@ -47,7 +47,6 @@ import (
"github.com/zap-proto/zip/wsx"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/types"
)
@@ -563,8 +562,11 @@ func (cc *collabConn) frame(ctx context.Context, data []byte) {
}
}
// auth verifies the in-band token exactly like the RPC lane (same token, same
// workspace pin, same membership gate) and on success joins the room.
// auth verifies the in-band credential exactly like the RPC lane (same seam, same
// workspace pin, same membership gate) and on success joins the room. The frame
// carries the credential itself, so it resolves through identity.verified rather
// than off the request's carriers — an IAM access token or team's HS256 token, the
// same two lanes in the same order.
func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
if _, ok := cc.sessions[docName]; ok {
return // duplicate Auth for a live session — idempotent
@@ -583,12 +585,12 @@ func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
deny("malformed auth")
return
}
t, err := token.Decode(raw, cc.svc.secret, true)
if err != nil || t.Account == "" {
cl, err := cc.svc.ident.verified(ctx, raw)
if err != nil {
deny("invalid session token")
return
}
org := t.Org()
org := cl.org
if org == "" {
deny("invalid session token")
return
@@ -598,7 +600,7 @@ func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
deny("malformed documentId")
return
}
if t.Workspace != "" && t.Workspace != doc.workspace {
if cl.workspace != "" && cl.workspace != doc.workspace {
deny("document not found")
return
}
@@ -606,12 +608,7 @@ func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
deny("collaborator unavailable")
return
}
w, err := cc.svc.accounts.WorkspaceByUUID(ctx, org, doc.workspace)
if err != nil {
deny("document not found")
return
}
if _, ok := cc.svc.accounts.Membership(ctx, w.ID, t.Account); !ok {
if _, err := cc.svc.ident.admit(ctx, cl, doc.workspace); err != nil {
deny("document not found")
return
}
+1 -1
View File
@@ -74,7 +74,7 @@ func collabHarness(t *testing.T) (svc *collabService, docName, memberTok string)
if err != nil {
t.Fatal(err)
}
svc = &collabService{vfs: vfs, accounts: mounted.State.accounts, secret: testSecret, hub: newCollabHub(vfs)}
svc = &collabService{vfs: vfs, accounts: mounted.State.accounts, ident: testIdent(mounted.State.accounts), hub: newCollabHub(vfs)}
docName = ws.UUID + "|document:class:Document|doc-1|content"
return svc, docName, tok
}
+1
View File
@@ -58,6 +58,7 @@ func gateApp(t *testing.T, commerce types.CommerceClient, planEnt func(context.C
t.Cleanup(func() { _ = store.Close() })
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret},
log: luxlog.New("test"),
commerce: commerce,
+20 -33
View File
@@ -91,8 +91,7 @@ const maxBlobSize = 100 << 20
// op cannot be wrapped by Mount's guard, so it asks for itself — see typed.go.
type filesService struct {
vfs types.VFSClient
accounts *accountStore
secret string
ident *identity
degraded bool
}
@@ -116,32 +115,20 @@ func (s *filesService) register(app cloud.Router, guard guardFn) {
zip.Delete(g, "/files/:workspace/:filename", s.deleteBlob, zip.WithStatus(http.StatusNoContent))
}
// principal resolves (account, org) from the request's VERIFIED session or
// workspace token (bearer or the HttpOnly account cookie) — the shared
// orgPrincipal resolution (billing.go).
func (s *filesService) principal(c *zip.Ctx) (account, org string, err error) {
return orgPrincipal(c, s.secret)
}
// authorize asserts :workspace belongs to org AND the caller is a MEMBER of it
// (Red F-C: bind files to workspace membership, not just same-org). Any failure is
// a 404 — no oracle distinguishing "no such workspace", "not your org", or "not a
// member". It takes the CONTEXT rather than the request because it needs nothing
// else off the wire, which is what lets the typed delete and the untyped
// upload/download share the one gate.
func (s *filesService) authorize(ctx context.Context, account, org, wsUUID string) error {
wsUUID = strings.TrimSpace(wsUUID)
if wsUUID == "" {
// authorize asserts :workspace belongs to the caller's org AND the caller is a
// MEMBER of it (Red F-C: bind files to workspace membership, not just same-org) —
// identity.admit, the one membership gate. Any failure is a 404: no oracle
// distinguishing "no such workspace", "not your org", or "not a member". It takes
// the CONTEXT rather than the request because it needs nothing else off the wire,
// which is what lets the typed delete and the untyped upload/download share it.
func (s *filesService) authorize(ctx context.Context, cl caller, wsUUID string) error {
if strings.TrimSpace(wsUUID) == "" {
return zip.ErrBadRequest("workspace required")
}
if s.accounts == nil {
if s.ident == nil || s.ident.accounts == nil {
return zip.Errorf(http.StatusServiceUnavailable, "team: file storage unavailable")
}
w, err := s.accounts.WorkspaceByUUID(ctx, org, wsUUID)
if err != nil {
return zip.ErrNotFound("workspace not found")
}
if _, ok := s.accounts.Membership(ctx, w.ID, account); !ok {
if _, err := s.ident.admit(ctx, cl, wsUUID); err != nil {
return zip.ErrNotFound("workspace not found")
}
return nil
@@ -152,12 +139,12 @@ func (s *filesService) authorize(ctx context.Context, account, org, wsUUID strin
// (front.ts: formData.append('file', file, uuid)). Response body is irrelevant
// (uploadFile discards it); we echo the id for curl/debug.
func (s *filesService) upload(c *zip.Ctx) error {
account, org, err := s.principal(c)
cl, err := s.ident.who(c)
if err != nil {
return zip.ErrUnauthorized("invalid session token")
}
ws := c.Param("workspace")
if err := s.authorize(c.Context(), account, org, ws); err != nil {
if err := s.authorize(c.Context(), cl, ws); err != nil {
return err
}
fh, err := c.Fiber().FormFile("file")
@@ -189,7 +176,7 @@ func (s *filesService) upload(c *zip.Ctx) error {
if len(data) == 0 {
return zip.ErrBadRequest("empty upload")
}
if err := s.vfs.Put(c.Context(), blobKey(org, ws, blobID), data); err != nil {
if err := s.vfs.Put(c.Context(), blobKey(cl.org, ws, blobID), data); err != nil {
// deps.VFS is DisabledVFS (fail-closed) unless the operator wires a real VFS
// backend — an honest 502, never a silent success.
return zip.Errorf(http.StatusBadGateway, "file storage unavailable")
@@ -203,19 +190,19 @@ func (s *filesService) upload(c *zip.Ctx) error {
// image/svg+xml → active XSS). Anything not a recognized raster image is served
// inert: application/octet-stream + attachment + nosniff.
func (s *filesService) download(c *zip.Ctx) error {
account, org, err := s.principal(c)
cl, err := s.ident.who(c)
if err != nil {
return zip.ErrUnauthorized("invalid session token")
}
ws := c.Param("workspace")
if err := s.authorize(c.Context(), account, org, ws); err != nil {
if err := s.authorize(c.Context(), cl, ws); err != nil {
return err
}
blobID := strings.TrimSpace(c.Query("file"))
if blobID == "" {
return zip.ErrBadRequest("file (blob id) required")
}
data, err := s.vfs.Get(c.Context(), blobKey(org, ws, blobID))
data, err := s.vfs.Get(c.Context(), blobKey(cl.org, ws, blobID))
switch {
case errors.Is(err, types.ErrBlobNotFound), err == nil && data == nil:
// Genuine miss (working backend). A cross-org/-workspace blobId is a DIFFERENT
@@ -270,12 +257,12 @@ func (s *filesService) deleteBlob(ctx context.Context, in *blobRef) (*none, erro
if s.degraded {
return nil, unavailable()
}
account, org, err := sessionOf(ctx, s.secret)
cl, err := callerOf(ctx, s.ident)
if err != nil {
return nil, zip.ErrUnauthorized("invalid session token")
}
ws := in.Workspace
if err := s.authorize(ctx, account, org, ws); err != nil {
if err := s.authorize(ctx, cl, ws); err != nil {
return nil, err
}
// deleteFile calls getFileUrl(ws, file) with no filename → path segment == the
@@ -289,7 +276,7 @@ func (s *filesService) deleteBlob(ctx context.Context, in *blobRef) (*none, erro
// deleting never confirms existence and a foreign blobId is a harmless no-op.
// But a backend that is unavailable/disabled (any OTHER error) fails CLOSED with
// 502 — never a silent success lie, never a nil-deref 500.
if err := s.vfs.Delete(ctx, blobKey(org, ws, blobID)); err != nil && !errors.Is(err, types.ErrBlobNotFound) {
if err := s.vfs.Delete(ctx, blobKey(cl.org, ws, blobID)); err != nil && !errors.Is(err, types.ErrBlobNotFound) {
return nil, zip.Errorf(http.StatusBadGateway, "file storage unavailable")
}
return nil, nil
+1 -1
View File
@@ -534,7 +534,7 @@ func TestCallbackVerifiesOwner(t *testing.T) {
accounts: store,
cfg: config{serverSecret: testSecret, iamEndpoint: iam.URL, iamClientID: "hanzo-team", provider: "openid"},
log: luxlog.New("test"),
verify: verify,
ident: &identity{verify: verify, secret: testSecret, accounts: store},
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
g.register(app, func(h zip.Handler) zip.Handler { return h })
+10 -11
View File
@@ -260,22 +260,21 @@ func (g *api) sendInvite(c *zip.Ctx, params map[string]any) error {
}
// getMemberships is the account RPC "getMemberships" — the mid-session membership
// refresh. It reads the caller's LIVE org set from IAM (via the extra.user id the
// session token carries) so a user invited into a new org mid-session sees it
// without re-logging-in. On any IAM error, or a legacy token without extra.user,
// it falls back to the session's own signed orgs set — the refresh is best-effort
// and never strands the user.
// refresh. It reads the caller's LIVE org set from IAM (via the caller's
// `<owner>/<name>` id) so a user invited into a new org mid-session sees it without
// re-logging-in. On any IAM error, or a credential that names no username, it falls
// back to the caller's own verified org set — the refresh is best-effort and never
// strands the user.
func (g *api) getMemberships(c *zip.Ctx) error {
t, _, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return g.fail(c, statusUnauthorized(err.Error()))
}
session := orgsFromExtra(t.Extra)
user, _ := t.Extra["user"].(string)
if user == "" {
return g.ok(c, session) // legacy token: no IAM id to refresh against
session := cl.orgs
if cl.user == "" {
return g.ok(c, session) // no IAM id to refresh against
}
live, err := g.iamGetMemberships(c.Context(), user)
live, err := g.iamGetMemberships(c.Context(), cl.user)
if err != nil || len(live) == 0 {
if err != nil {
g.log.Warn("team: getMemberships — IAM refresh failed, serving session set", "err", err)
+2
View File
@@ -89,6 +89,7 @@ func TestSendInviteGuestOverCapObserved(t *testing.T) {
}
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret, iamEndpoint: iamSrv.URL, iamClientID: "hanzo-team", iamClientSecret: "team-secret", provider: "openid"},
log: luxlog.New("test"),
commerce: commerce,
@@ -149,6 +150,7 @@ func TestSendInviteGuestInfraErrorAdmits(t *testing.T) {
commerce := &fakeCommerce{err: fmt.Errorf("commerce not co-resident")}
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret, iamEndpoint: iamSrv.URL, iamClientID: "hanzo-team", iamClientSecret: "team-secret", provider: "openid"},
log: luxlog.New("test"),
commerce: commerce,
+87
View File
@@ -0,0 +1,87 @@
package team
// The workspace membership read, published on the internal plane.
//
// The workspaces/members tables have one writer and it is this process. A peer
// that must decide something about a workspace — meet, deciding whether a caller
// may join a room — used to read that decision off a signed workspace claim,
// which is the second bearer authority the estate is retiring. Once the caller
// arrives with an IAM identity and no workspace claim, the rows are the only
// place the answer exists, and they live here.
//
// The projection is deliberately narrow: whether there is a row, and the role on
// it. A peer deciding a join needs exactly that; handing over the member record
// would put a workspace's roster on the wire for one boolean.
import (
"context"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
// exposeMember publishes the membership read. Mount calls it.
func exposeMember(accounts *accountStore) {
zip.Post[plane.MemberIn, plane.Member](cloud.Plane(), "/team/member",
func(ctx context.Context, in *plane.MemberIn) (*plane.Member, error) {
return memberOf(ctx, accounts, in)
},
zip.WithOperationID(plane.TeamMember),
zip.WithSummary("This person's role in that workspace"))
}
// memberOf answers whether the named account holds a member row in the named
// workspace of the CALLER'S OWN org, and with what role.
//
// The org is taken from the CALL rather than from the argument. That is not a
// guarantee about the peer — a peer states its own caller (cloud.For / cloud.As),
// so a compromised or buggy one can name any org, and this op is only ever as
// tenant-safe as the process asking. It is the internal plane: peers are trusted,
// the socket is a UDS on the same node, and there is no authority here a peer could
// not also get by asking for the org it wanted. What taking it off the call DOES
// buy is that the org travels with the identity the asking process authenticated,
// so a peer cannot answer one caller's question with another caller's tenant by
// mistake — the failure mode that a workspace-plus-org argument invites.
//
// A call carrying no org is refused, not answered with "not a member": a refusal is
// a fault the operator can see, while a false negative is a join that silently
// stops working.
//
// It fails closed on a store that is not open: this process owns the store, so a
// nil handle is a boot-order fault, and "not a member" would read as a real
// answer about a workspace nobody could check.
func memberOf(ctx context.Context, accounts *accountStore, in *plane.MemberIn) (*plane.Member, error) {
org := cloud.Who(ctx).Org
if org == "" {
return nil, zip.ErrUnauthorized("team: no org on the call")
}
if accounts == nil {
return nil, zip.Errorf(503, "team: account store not open in the process that owns it")
}
if in.Workspace == "" || in.Subject == "" {
return nil, zip.ErrBadRequest("team: workspace and subject are required")
}
// The subject → account resolution is THIS package's, and it is the STORE's:
// AccountForSubject is the same function the request lane uses, so a peer and a
// browser resolve one identity to one account or the peer is told there is none.
// A peer that derived its own would be a second derivation of one address — and
// it would have to reproduce the subject-only rule that keeps a token with no
// `sub` from resolving to whoever its username names.
account, ok := accounts.AccountForSubject(ctx, org, in.Subject)
if !ok {
return &plane.Member{}, nil
}
w, err := accounts.WorkspaceByUUID(ctx, org, in.Workspace)
if err != nil {
// Not this tenant's workspace, or none at all — the same answer either way,
// so a probe learns nothing about what exists in another org.
return &plane.Member{}, nil
}
role, ok := accounts.Membership(ctx, w.ID, account)
if !ok {
return &plane.Member{}, nil
}
return &plane.Member{Member: true, Role: role, Account: account}, nil
}
+3
View File
@@ -432,6 +432,7 @@ func TestSendInviteWritesMembershipAndRow(t *testing.T) {
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret, iamEndpoint: iamSrv.URL, iamClientID: "hanzo-team", iamClientSecret: "team-secret", provider: "openid"},
log: luxlog.New("test"),
}
@@ -502,6 +503,7 @@ func TestSendInviteRequiresAdmin(t *testing.T) {
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret, iamEndpoint: iamSrv.URL, iamClientID: "hanzo-team", iamClientSecret: "team-secret"},
log: luxlog.New("test"),
}
@@ -555,6 +557,7 @@ func TestGetMembershipsRefresh(t *testing.T) {
t.Cleanup(func() { _ = store.Close() })
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret, iamEndpoint: iamSrv.URL, iamClientID: "hanzo-team", iamClientSecret: "team-secret"},
log: luxlog.New("test"),
}
+22 -5
View File
@@ -89,11 +89,23 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
}
// The identity seam: ONE answer to "who is calling, and what may they touch",
// shared by every team surface. The IAM validator is the SAME RS256/JWKS trust
// anchor the identity boundary and the OAuth callback use; the HS256 secret is
// the fallback arm; the account store is the membership authority the IAM lane
// authorizes a named workspace against.
ident := &identity{
verify: cloud.NewTokenValidator(cfg.iamEndpoint).Validate,
secret: cfg.serverSecret,
accounts: accounts,
audience: sessionAudience(cfg),
}
trans := &transServer{
store: newStore(filepath.Join(root, "workspaces")),
hier: buildHierarchy(modelJSON),
hub: newHub(),
secret: cfg.serverSecret,
ident: ident,
accounts: accounts,
bots: agentsBotLister, // the ONE in-process seam to the agents registry
log: log,
@@ -121,7 +133,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
accounts: accounts, trans: trans, cfg: cfg, log: log,
// The IAM boundary: the SAME RS256/JWKS validator the identity middleware
// uses, so the OAuth callback's `owner` claim is VERIFIED, never trusted raw.
verify: cloud.NewTokenValidator(cfg.iamEndpoint).Validate,
ident: ident,
// The entitlement seams: commerce answers "does the org's plan license
// 'team'"; plan answers the plan's entitlement block (team.guests cap).
commerce: deps.Commerce,
@@ -174,22 +186,27 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// Files plane: the workspace blob store the Team front's UPLOAD_URL/FILES_URL
// hit, backed by cloud's canonical VFS seam (deps.VFS) and org-scoped by the
// verified session token — the SAME isolation invariant as the docs store.
files := &filesService{vfs: deps.VFS, accounts: accounts, secret: cfg.serverSecret, degraded: degraded}
files := &filesService{vfs: deps.VFS, ident: ident, degraded: degraded}
files.register(app, guard)
// Billing plane: the go:embed'd usage/wallet page (/billing/ui/*) + the
// plan/seats read (/billing/plan) — session-gated, org-scoped through the
// SAME commerce/plan seams the login gate (entitle.go) uses.
billing := &billingService{accounts: accounts, commerce: deps.Commerce, planEnt: plan.Entitlements, secret: cfg.serverSecret, degraded: degraded}
billing := &billingService{accounts: accounts, commerce: deps.Commerce, planEnt: plan.Entitlements, ident: ident, degraded: degraded}
billing.register(app, guard)
// Collaborator planes, both app-level under /collaborator: the markup
// snapshot RPC (collab.go, POST /collaborator/rpc/:documentId) and the live
// hocuspocus Y.js WebSocket (collabws.go, GET /collaborator) — one service,
// one tenancy gate, one VFS seam.
collab := &collabService{vfs: deps.VFS, accounts: accounts, secret: cfg.serverSecret, hub: newCollabHub(deps.VFS), degraded: degraded}
collab := &collabService{vfs: deps.VFS, accounts: accounts, ident: ident, hub: newCollabHub(deps.VFS), degraded: degraded}
collab.register(app, guard)
// The membership read a PEER process needs: meet decides a room join and does
// not own these rows, so the answer travels rather than being read off a signed
// claim (member_rpc.go).
exposeMember(accounts)
mounted = &cloud.Service[state]{Base: cloud.NewBase(deps, "team"), State: state{accounts: accounts, trans: trans}}
log.Info("team mounted", "brand", deps.Brand, "iam", cfg.iamEndpoint, "client", cfg.iamClientID, "degraded", degraded)
return nil
+86 -34
View File
@@ -22,7 +22,6 @@ import (
luxlog "github.com/luxfi/log"
"github.com/valyala/fasthttp"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/wsx"
@@ -53,13 +52,13 @@ type BotLister func(ctx context.Context, org string) ([]Bot, error)
// transServer holds the transactor's shared, process-lifetime state: the per-
// workspace SQLite docs store (the structured data plane — no KV, no Postgres),
// the class hierarchy parsed from the embedded model, the live-broadcast hub, the
// shared HS256 secret, and the two roster sources (the account store's members +
// identity seam, and the two roster sources (the account store's members +
// the in-process agents lister).
type transServer struct {
store *docStore
hier *hierarchy
hub *hub
secret string
ident *identity // who is calling, and what may they touch (account.go)
accounts *accountStore // human members (this deployment's workspaces)
bots BotLister // bot members (the org's in-process agents)
runAgent AgentRunner // the Chunter responder's LLM seam (agents.RunOnBehalf); nil = responder OFF
@@ -105,7 +104,9 @@ func init() {
"rather than being long-lived like the session token. It is decoded and verified "+
"(signature and expiry) BEFORE the upgrade, so a bad one is a 401 and never a socket "+
"that is accepted and then dropped, and it must carry both an account and a "+
"workspace claim.\n\n"+
"workspace claim. Nothing ambient authorizes this socket: a WebSocket is exempt from "+
"CORS, so a cookie-borne credential would make the Origin check the only access "+
"control on the whole data plane.\n\n"+
"The tenant is the token's SIGNED org claim and it keys every store path, so no "+
"header can name another workspace's data. The upgrade ALSO refuses a browser Origin "+
"outside the team surfaces with 403 — otherwise any page could open an authenticated "+
@@ -116,24 +117,30 @@ func init() {
"workspace people without a separate sync call.")
}
// serveWS decodes + AUTHORIZES the workspace token BEFORE the WebSocket upgrade
// (fail-secure: a bad token is a 401, never an upgraded-then-dropped socket),
// then upgrades and runs the frame loop. The org is the token's VERIFIED extra.org
// claim — the tenant key for every store path — never a client header.
// serveWS AUTHORIZES the caller BEFORE the WebSocket upgrade (fail-secure: a
// refusal is a 401, never an upgraded-then-dropped socket), then upgrades and runs
// the frame loop. The org is the VERIFIED tenant — the key for every store path —
// never a client header.
//
// The path segment carries whichever lane the caller is on, and a UUID is not a
// JWT so the two can never be read as each other:
//
// THE PATH SEGMENT IS THE CREDENTIAL — the workspace token selectWorkspace minted,
// whose signed claims name both the account and the workspace. Nothing ambient
// authorizes this socket; see admitWS for why it must stay that way and what the
// IAM lane here will look like.
func (srv *transServer) serveWS(c *zip.Ctx) error {
raw := c.Param("token")
t, err := token.Decode(raw, srv.secret, true)
if err != nil || t.Account == "" || t.Workspace == "" {
cl, ws, err := srv.admitWS(c.Param("token"))
if err != nil || ws == "" {
return zip.ErrUnauthorized("invalid workspace token")
}
org := t.Org()
sess := &session{
server: srv,
store: srv.store,
hier: srv.hier,
account: t.Account,
org: org,
workspace: t.Workspace,
account: cl.account,
org: cl.org,
workspace: ws,
sessionID: c.Query("sessionId"),
}
return wsx.Upgrade(func(conn *wsx.Conn) error {
@@ -147,6 +154,39 @@ func (srv *transServer) serveWS(c *zip.Ctx) error {
}, wsx.Config{CheckOrigin: wsOriginOK})(c)
}
// admitWS resolves the socket's caller and the workspace it may open, from the
// CREDENTIAL IN THE PATH SEGMENT and nothing ambient.
//
// THE TRANSACTOR HAS NO IAM LANE, and that is a decision rather than an omission.
// A browser can put a credential on a WebSocket in exactly two places: the URL, or
// a cookie. The URL is where the HS256 workspace token already sits, which is
// survivable only because that token is scoped to one workspace for twelve hours —
// an estate-wide IAM bearer in a path that proxies and access logs record is not.
// And the cookie is worse here than anywhere else in this file: a WebSocket is
// EXEMPT FROM CORS, so a foreign page may open one and read every frame, and
// SameSite=Lax is scoped to the registrable domain — so any first-party page that
// can be made to run script opens an authenticated workspace socket with the
// victim's ambient cookie and reads and writes the whole stream. An Origin check is
// then the only access control, which makes one wildcard in an allowlist a total
// compromise of the data plane.
//
// The shape that works is the one the sibling socket already uses: collabws.go
// upgrades first and takes the credential IN-BAND in an Auth frame, so nothing
// ambient authorizes anything. Giving the transactor that lane means the client
// sends a frame it does not send today, which is a front change and therefore a
// later phase — named here so the next reader implements THAT rather than
// re-deriving the cookie.
func (srv *transServer) admitWS(seg string) (caller, string, error) {
cl, err := srv.ident.hs256(seg)
if err != nil {
return caller{}, "", err
}
// An HS256 SESSION token names no workspace, and that is not an error here: the
// statistics read answers it with an empty session map. serveWS, which cannot
// open a socket onto nothing, imposes its own requirement.
return cl, cl.workspace, nil
}
// wsOriginOK is the browser-Origin gate on the transactor upgrade: without it
// ANY page could open an authenticated socket with a token it holds (or lure a
// logged-in browser into one). Absent Origin is allowed — non-browser clients
@@ -155,9 +195,25 @@ func wsOriginOK(ctx *fasthttp.RequestCtx) bool {
return originAllowed(string(ctx.Request.Header.Peek("Origin")), string(ctx.Host()))
}
// originAllowed admits: no Origin (non-browser), the request's own host, the
// team surfaces (hanzo.team, team.hanzo.ai, api.hanzo.team), any *.hanzo.ai
// (+ apex), and loopback for local dev. Everything else is refused.
// teamOrigins is the EXPLICIT set of pages allowed to open a team socket. It is a
// NAMED set with no suffix arm, and the missing arm is the point.
//
// This gate used to admit any *.hanzo.ai host. A WebSocket is exempt from CORS —
// a page may open one cross-origin and read every frame — so this check is the
// access control for the socket, not a hint about it. A wildcard over a registrable
// domain therefore means every first-party host is part of the team data plane's
// TCB: one marketing subdomain, one preview host, one page that renders
// user-supplied markdown, and a script there opens an authenticated workspace
// socket. The blast radius of a wildcard here is the whole workspace, so the set is
// enumerated and grows only on purpose.
var teamOrigins = map[string]bool{
"hanzo.team": true, "team.hanzo.ai": true, "api.hanzo.team": true,
"hanzo.ai": true, "localhost": true, "127.0.0.1": true,
}
// originAllowed admits: no Origin (a non-browser client sends none, and a browser
// cannot omit it), the request's own host, and the named team surfaces. Everything
// else is refused.
func originAllowed(origin, host string) bool {
origin = strings.TrimSpace(origin)
if origin == "" {
@@ -170,12 +226,7 @@ func originAllowed(origin, host string) bool {
if strings.EqualFold(u.Host, host) {
return true
}
switch h := strings.ToLower(u.Hostname()); h {
case "hanzo.team", "team.hanzo.ai", "api.hanzo.team", "hanzo.ai", "localhost", "127.0.0.1":
return true
default:
return strings.HasSuffix(h, ".hanzo.ai")
}
return teamOrigins[strings.ToLower(u.Hostname())]
}
// statsIn is the statistics read's whole input: the workspace token, which the
@@ -235,25 +286,26 @@ type statsOut struct {
Admin bool `json:"admin"`
}
// Statistics returns the transactor's live sessions for the workspace the
// caller's token names — the endpoint the front's workspace switcher and server
// panel poll on the transactor base. The token is verified exactly like the
// WebSocket upgrade is, and activeSessions carries ONLY that token's own
// workspace, never another tenant's sessions. An invalid or expired token is
// 401.
// Statistics returns the transactor's live sessions for the workspace the caller's
// credential names — the endpoint the front's workspace switcher and server panel
// poll on the transactor base. `token` carries the same two lanes the socket's path
// segment does: a workspace UUID names the workspace and is authorized against the
// membership rows, an HS256 workspace token names it in its signed claims.
// activeSessions carries ONLY that one workspace, never another tenant's sessions.
// An unverifiable credential, or one the caller is no member under, is 401.
//
// Example: {"token": "eyJhbGciOiJIUzI1NiJ9…"}
func (srv *transServer) statistics(ctx context.Context, in *statsIn) (*statsOut, error) {
if srv.degraded {
return nil, unavailable()
}
t, err := token.Decode(in.Token, srv.secret, true)
if err != nil || t.Account == "" {
_, ws, err := srv.admitWS(in.Token)
if err != nil {
return nil, zip.ErrUnauthorized("invalid token")
}
active := map[string][]statsUser{}
if t.Workspace != "" {
active[t.Workspace] = srv.hub.users(t.Workspace)
if ws != "" {
active[ws] = srv.hub.users(ws)
}
// Metrics needs no initialiser: the zero value of an empty struct already
// marshals to the `{}` the front reads, so there is nothing to allocate.
+15 -5
View File
@@ -160,8 +160,16 @@ func TestEnvelopeWrapsRPC(t *testing.T) {
}
// TestOriginAllowed is the WS-upgrade Origin allow-list table: absent Origin
// (non-browser) and the team/hanzo surfaces are admitted; everything else —
// including lookalike registered domains — is refused before the upgrade.
// (non-browser) and the NAMED team surfaces are admitted; everything else — a
// lookalike registered domain, and any other first-party host — is refused before
// the upgrade.
//
// console.hanzo.ai USED to be admitted, by a `.hanzo.ai` suffix arm. A WebSocket is
// exempt from CORS, so this list is the socket's access control rather than a hint
// about it, and a wildcard over the registrable domain put every first-party host
// inside the workspace data plane's trust boundary: one page anywhere in the estate
// running attacker script reads and writes the whole stream. A host that genuinely
// needs the socket is named here on purpose.
func TestOriginAllowed(t *testing.T) {
cases := []struct {
origin, host string
@@ -172,14 +180,16 @@ func TestOriginAllowed(t *testing.T) {
{"https://hanzo.team", "api.hanzo.ai", true}, // team surface
{"https://team.hanzo.ai", "hanzo.team", true}, // team surface
{"https://api.hanzo.team", "hanzo.team", true}, // team surface
{"https://console.hanzo.ai", "hanzo.team", true}, // *.hanzo.ai
{"https://console.hanzo.ai", "hanzo.team", false}, // first-party, but NOT a team surface
{"https://hanzo.ai", "hanzo.team", true}, // apex
{"http://localhost:8087", "localhost:8000", true}, // local dev
{"https://evil.example", "hanzo.team", false}, // foreign origin
{"https://evilhanzo.ai", "hanzo.team", false}, // suffix lookalike
{"https://hanzo.ai.evil.example", "hanzo.team", false},
{"null", "hanzo.team", false}, // opaque origin
{"://bad", "hanzo.team", false}, // unparseable
{"https://chat.hanzo.ai", "hanzo.team", false}, // no wildcard: the socket is not chat's
{"https://api.hanzo.ai", "api.hanzo.ai", true}, // its own host still serves itself
{"null", "hanzo.team", false}, // opaque origin
{"://bad", "hanzo.team", false}, // unparseable
}
for _, c := range cases {
if got := originAllowed(c.origin, c.host); got != c.want {
+25 -28
View File
@@ -7,19 +7,18 @@ package team
//
// - the GATEWAY principal (X-User-Id / X-Org-Id, validated by the identity
// boundary), which cloud.Bridge parks on the context — the bots surface;
// - team's OWN HS256 session token (Authorization: Bearer, else the HttpOnly
// account-token cookie), minted by this service's OAuth callback and signed
// with SERVER_SECRET — the billing and files surfaces.
// - a TEAM CALLER (identity.who, account.go): an IAM access token, else team's
// own HS256 session token, riding a header or a cookie — the billing, files
// and collaborator surfaces.
//
// The second one is why cloud.Request is here: a team session token rides in a
// header or a cookie, and principal.OrgFrom cannot carry either. Resolving it is
// an identity gate, exactly the class the pin admits — and keeping it in THIS
// file is why the pin has one team entry instead of one per plane. The cookie
// WRITER is here for the same reason and no other: the account-token cookie is
// the other end of that same identity, set on the response.
// The second one is why cloud.Request is here: both of those credentials ride in
// a header or a cookie, and principal.OrgFrom cannot carry either. Resolving it is
// an identity gate, exactly the class the pin admits. The cookie WRITER is here
// for the same reason and no other: the account-token cookie is the other end of
// that same identity, set on the response.
//
// EVERY resolver below fails closed off the HTTP path: the CLI projection's
// LocalInvoke runs an op with no request at all, so `tokenOf`, `sessionOf`,
// LocalInvoke runs an op with no request at all, so `callerOf`, `sessionOf`,
// `admin` and `cookie` find nothing and the op refuses rather than inventing an
// identity or claiming to have signed out a browser that was never there.
@@ -31,7 +30,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/team/token"
)
// zipdoc lifts the doc comment off each typed op and its In/Out fields into
@@ -73,35 +71,34 @@ func admin(ctx context.Context) bool {
return ok && c.IsAdmin()
}
// tokenOf resolves the request's VERIFIED team session or workspace token — the
// SAME sessionToken decode the untyped handlers do, reached from a typed op. Off
// the HTTP path there is no request and therefore no token, which fails closed
// with the same error an absent one gives.
// callerOf resolves the request's VERIFIED caller — the SAME identity.who the
// untyped handlers resolve, reached from a typed op. Off the HTTP path there is no
// request and therefore no credential, which fails closed with the same error an
// absent one gives.
//
// It is the token, not the (account, org) pair, because the collaborator plane
// gates on the WORKSPACE claim too: a workspace token names its workspace, and a
// It is the caller, not the (account, org) pair, because the collaborator plane
// gates on the WORKSPACE too: an HS256 workspace token names its workspace, and a
// documentId for a different one is refused.
func tokenOf(ctx context.Context, secret string) (*token.Token, error) {
func callerOf(ctx context.Context, id *identity) (caller, error) {
c, ok := cloud.Request(ctx)
if !ok {
return nil, errNoOrg
return caller{}, errNoOrg
}
t, _, err := sessionToken(c, secret)
return t, err
return id.who(c)
}
// sessionOf resolves (account, org) from that same verified token, refusing one
// that carries no tenant claim — the orgPrincipal rule the untyped billing and
// files handlers apply, stated once here for the typed ops.
func sessionOf(ctx context.Context, secret string) (account, org string, err error) {
t, err := tokenOf(ctx, secret)
// sessionOf resolves (account, org) from that same verified caller, refusing one
// that carries no tenant — the orgPrincipal rule the untyped billing and files
// handlers apply, stated once here for the typed ops.
func sessionOf(ctx context.Context, id *identity) (account, org string, err error) {
cl, err := callerOf(ctx, id)
if err != nil {
return "", "", err
}
if org = t.Org(); org == "" {
if cl.org == "" {
return "", "", errNoOrg
}
return t.Account, org, nil
return cl.account, cl.org, nil
}
// cookie writes one of team's own browser cookies from a TYPED op, and reports
+142
View File
@@ -0,0 +1,142 @@
// Package iamtest is a REAL IAM issuer for tests: a keypair, a JWKS endpoint, and
// tokens signed with it.
//
// It exists because a stubbed validator cannot observe the bugs that live in the
// claim mapping. What makes an IAM lane safe is HOW a verified token becomes a
// principal — which claim is read for the subject, what happens when `sub` is
// absent, whether an id_token is distinguishable from an access token — and a stub
// that returns a pre-built identity has already made every one of those decisions
// itself. A test built on one asserts its own fixture.
//
// It is one package rather than a copy per caller for the same reason everything
// else here is: two fixtures for one wire drift, and the one that drifts is the one
// whose test then passes against code that would fail in production.
//
// SIGNING HERE GRANTS NOTHING. The key is generated per test and its JWKS is served
// on a loopback address the test itself owns; no deployment trusts either. This is
// the "forging a bad token is how a verifier gets tested" case the token gate names
// as out of scope, made explicit and shared instead of retyped.
package iamtest
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Issuer is the issuer the minted tokens name and the validator under test trusts.
const Issuer = "https://test.iam"
// Audience is the app the minted tokens are FOR, unless a case overrides it. A
// resource server that gates on audience is told this value; one that does not
// ignores it.
const Audience = "hanzo-team"
// Claims is the claim vocabulary a test varies. Every field maps to a real IAM
// claim; nothing here is a cloud-side invention.
type Claims struct {
// Sub is the `sub` claim. EMPTY MEANS OMITTED — the subject-confusion case is
// a token that carries none, which a typed struct could not express.
Sub string
// PreferredUsername is the IAM username claim, the half of the canonical-user-id
// fallback chain that a subject-less token resolves through.
PreferredUsername string
// Name is IAM's display-name claim, the last fallback in that chain.
Name string
// Owner is the home org — the tenant every account-store query scopes to.
Owner string
// TokenType is IAM's `tokenType`. Empty defaults to an access token; "-" omits
// the claim entirely, which is the pre-rollout token shape.
TokenType string
// Orgs is the signed membership set. Its FIRST entry is the home org — the
// tenant rule the estate states in idClaims.homeOrg — so a case that omits it
// mints a token with no home, which is what a machine credential looks like.
Orgs []map[string]any
// Aud overrides the audience, for the case where a token was minted for a
// DIFFERENT app than the one being asked to accept it.
Aud string
// Exp defaults to an hour out. A past value mints an expired token.
Exp time.Time
}
// Issuer0 is a signing issuer plus its published JWKS.
type Issuer0 struct {
key *rsa.PrivateKey
kid string
// URL is the JWKS endpoint. Point a validator at it with CLOUD_JWKS_URL.
URL string
}
// New stands up the JWKS endpoint and points cloud's validator at it through
// CLOUD_JWKS_URL — the SAME override a deployment uses to pin a custom JWKS, so
// the validator under test is assembled exactly as production assembles it.
func New(t *testing.T) *Issuer0 {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
iss := &Issuer0{key: key, kid: "cert-hanzo"}
body, _ := json.Marshal(map[string]any{"keys": []map[string]any{{
"kty": "RSA", "kid": iss.kid, "use": "sig", "alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()),
}}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
iss.URL = srv.URL
t.Setenv("CLOUD_JWKS_URL", srv.URL)
return iss
}
// Sign mints a signed token carrying exactly the claims given, as a plain map so a
// test can OMIT one.
func (i *Issuer0) Sign(t *testing.T, c Claims) string {
t.Helper()
if c.Exp.IsZero() {
c.Exp = time.Now().Add(time.Hour)
}
if c.TokenType == "" {
c.TokenType = "access-token"
}
if c.Aud == "" {
c.Aud = Audience
}
claims := jwt.MapClaims{
"iss": Issuer,
"aud": c.Aud,
"exp": c.Exp.Unix(),
"iat": time.Now().Unix(),
}
for k, v := range map[string]string{
"sub": c.Sub, "preferred_username": c.PreferredUsername,
"name": c.Name, "owner": c.Owner,
} {
if v != "" {
claims[k] = v
}
}
if c.TokenType != "-" {
claims["tokenType"] = c.TokenType
}
if c.Orgs != nil {
claims["orgs"] = c.Orgs
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = i.kid
raw, err := tok.SignedString(i.key)
if err != nil {
t.Fatalf("iamtest: sign: %v", err)
}
return raw
}
+1 -1
View File
@@ -434,7 +434,7 @@ func SanitizeIdentity(v *identityValidator) zip.Handler {
// (principal.Mint). The headers above are the contract everything
// DOWNSTREAM reads; this is the fact a middleware reads when it cannot
// prove it is downstream — see principal.Mint.
principal.Mint(c, principal.Principal{Org: effOrg, User: claims.userID()})
principal.Mint(c, principal.Principal{Org: effOrg, User: claims.userID(), Subject: strings.TrimSpace(claims.Subject)})
return c.Continue()
}
+37
View File
@@ -92,6 +92,16 @@ const (
IAMMailable = "iam_mailable"
// TeamMember answers "what is this person's role in that workspace?" for a
// caller that holds an IAM identity and no workspace claim.
//
// It is on the plane because the process that DECIDES a room join (meet) and
// the process that owns the workspace membership rows (team) are different
// ones. Before it, meet could only read a role a workspace token had signed —
// which is the second bearer authority the estate is retiring, so the decision
// had nowhere else to come from.
TeamMember = "team_member"
GitFiles = "git_files"
GitImport = "git_import"
GitInbound = "git_inbound"
@@ -365,6 +375,33 @@ type Roster struct {
Recipients []Recipient `json:"recipients"` // everyone in the org who may be mailed; empty is a real answer, not an error
}
// ---- team ------------------------------------------------------------------
// MemberIn names the workspace and the person a membership question is about.
// The ORG is not here and cannot be: it is the tenancy key of every workspace
// row, so a caller able to pass it could read another tenant's roster.
type MemberIn struct {
// Workspace is the workspace uuid, scoped to the caller's org on the read.
Workspace string `json:"workspace"`
// Subject is the IAM subject, NOT a team account id. team owns the join from
// one to the other — it is the join that created the rows — so a peer that
// computed its own would be a second derivation of the same address, which is
// how two layers end up naming different accounts for one person.
Subject string `json:"subject"`
}
// Member is what the rows say. Role and Account are empty exactly when Member is
// false, so a caller cannot mistake "no row" for a role or an identity.
type Member struct {
// Member reports whether the subject holds a row in that workspace.
Member bool `json:"member"`
// Role is the workspace role on that row (owner | admin | member | guest).
Role string `json:"role"`
// Account is the team AccountUuid the subject resolved to — the identity the
// asking process attributes the person by, so it never derives one itself.
Account string `json:"account"`
}
// ---- git -------------------------------------------------------------------
// ImportIn asks git to create a repo and mirror an upstream into it. It exists
+17 -5
View File
@@ -33,11 +33,23 @@ var allowedTokenPrimitives = map[string]string{
"cutover deletes this package (the session lane reads the hanzo_iam_token the browser already " +
"holds, the workspace grant moves behind the authority). Do not add readers — the entries below " +
"are the complete set and it only shrinks.",
"apps/team/account.go": "reader of the condemned team token — dies with the cutover.",
"apps/team/collabws.go": "reader of the condemned team token — dies with the cutover.",
"apps/team/transactor.go": "reader of the condemned team token — dies with the cutover.",
"apps/team/typed.go": "reader of the condemned team token — dies with the cutover.",
"apps/analytics/team.go": "reader of the condemned team token — dies with the cutover.",
"apps/team/account.go": "the ONE reader inside apps/team, and the whole of the dual read: identity.who " +
"resolves an IAM access token first and falls back to this package's decode, so every other team " +
"surface resolves a caller and touches no algorithm. The fallback arm — and this import with it — is " +
"deleted when login mints IAM-only and front/love/analytics-collector verify IAM. Four readers " +
"(collabws, transactor, typed, and the files plane's helpers) left the set when the seam landed.\n\n" +
"IT GATES AUDIENCE, and the divergence is deliberate. THIS file's verification is the boundary's " +
"(cloud.NewTokenValidator), which does not gate `aud` — correctly, for an API door: a signature from " +
"a trusted issuer already proves IAM minted the token for one of its own apps, and the app-registry " +
"mirror that once checked which was deleted for drifting. A SESSION door is a different question. " +
"This lane turns a bearer into a signed-in person, and a token the user obtained for another app is " +
"not consent to that, so team narrows to a NAMED audience set at the resource server rather than at " +
"the door (identity.forThisDeployment; shape pinned by TestSessionAudienceIsNamedNotPatterned, " +
"behaviour by TestIAMLaneRefusesAForeignAudience). A second session-issuing surface owes the same " +
"gate — verification says IAM minted it, never that it was minted for you.",
"apps/analytics/team.go": "reader of the condemned team token — the ingest trust order already resolves " +
"a validated IAM bearer ahead of it (eventTenant step 1), so this arm dies with the cutover and " +
"needs no IAM lane of its own.",
"apps/meet/meet.go": "two halves: mints the LiveKit room-join token — the media server's own wire " +
"contract, an HS256 JWT under the LiveKit key the server itself validates, granting no Hanzo " +
"surface — and reads the condemned team token, which dies with the cutover.",
+54 -5
View File
@@ -32,7 +32,18 @@ type VerifiedIdentity struct {
// SuperAdmin predicate compares against the reserved admin org.
Owner string
// User is the canonical user id (sub, then preferred_username, then name).
//
// IT IS AN ATTRIBUTION KEY, NOT AN IDENTITY KEY. The fallback chain is what
// makes it useful for a log line and unusable for a lookup: a token with no
// `sub` resolves it to preferred_username, so two DIFFERENT subjects can
// present the same User, and a consumer that keys a record on it can be handed
// one subject's token and address another's row. Anything that resolves an
// account, a wallet or a member row keys on Subject and refuses it empty.
User string
// Subject is the `sub` claim VERBATIM — no fallback, empty when the token
// carries none. It is the one value that identifies exactly one IAM identity,
// so it is the key every account lookup uses.
Subject string
// Username is the IAM username — the `name` half of `<owner>/<name>`.
Username string
// Email is the validated `email` claim, when present.
@@ -53,6 +64,18 @@ type VerifiedIdentity struct {
// Expiry is the token's own `exp`. A session built on this token must not
// outlive it.
Expiry time.Time
// Audience is the validated `aud` claim — WHICH registered app IAM minted this
// token for. Validate does not gate on it (see below), so it is published for a
// consumer that must: a resource server narrower than the boundary — one whose
// credential is a SESSION rather than an API call — decides for itself which
// apps' tokens it accepts as one.
Audience []string
// TokenType is IAM's `tokenType` claim, which is one of the THREE things that
// distinguish an access token from an id_token — IAM's signer emits the same
// claim set into both but for aud/tokenType/nonce (middleware_identity.go), so
// signature and issuer alone cannot tell them apart. A consumer that accepts a
// bearer as a session must say which of them it means.
TokenType string
}
// TokenValidator verifies IAM access tokens exactly as the identity boundary does.
@@ -83,16 +106,19 @@ func (t *TokenValidator) Validate(raw string) (VerifiedIdentity, error) {
return VerifiedIdentity{}, err
}
id := VerifiedIdentity{
Owner: claims.Owner,
User: claims.userID(),
Username: claims.username(),
Email: claims.Email,
IsAdmin: claims.IsAdmin,
Owner: claims.Owner,
User: claims.userID(),
Subject: strings.TrimSpace(claims.Subject),
Username: claims.username(),
Email: claims.Email,
IsAdmin: claims.IsAdmin,
TokenType: strings.TrimSpace(claims.TokenType),
// The published shape stays []model.OrgRef: clients/team copies it verbatim into
// a session, so this is cloud's outward contract, not a second reading of the
// claim. ONE conversion, at the surface that publishes it.
Orgs: orgRefs(claims.Orgs),
}
id.Audience = append(id.Audience, claims.Audience...)
if claims.ExpiresAt != nil {
id.Expiry = claims.ExpiresAt.Time
}
@@ -106,6 +132,29 @@ func (t *TokenValidator) Validate(raw string) (VerifiedIdentity, error) {
return id, nil
}
// Home is the tenant this identity acts for: the FIRST entry of the signed
// membership set.
//
// IT IS NOT Owner, and the difference is a live defect class rather than a
// preference. `owner` carries the APPLICATION's org, so it is chosen by whichever
// app the caller authenticated through — a hanzo user arriving via lux-cloud
// presents owner="lux". The identity boundary refuses to derive a tenant from it
// for exactly that reason (idClaims.homeOrg, auth_identity.go), and a consumer that
// reads Owner as the tenant has re-opened what that accessor closed: it would scope
// every store query to an org the caller selected.
//
// Empty is a REFUSAL, not a default. A token carrying no membership set is either
// pre-v1.33.0 or a MACHINE credential (a client_credentials app or an API key is a
// member of nothing), and neither is a person with a home tenant. A caller that
// needs one must fail closed on empty rather than fall back to Owner — falling back
// is the defect, spelled slightly differently.
func (v VerifiedIdentity) Home() string {
if len(v.Orgs) == 0 {
return ""
}
return strings.TrimSpace(v.Orgs[0].Org)
}
// jwksURLFor resolves the JWKS endpoint for an issuer: the CLOUD_JWKS_URL override
// when set, else the HIP-0111 convention {issuer}/v1/iam/.well-known/jwks. ONE
// derivation, shared by Config load and NewTokenValidator, so a deployment that
+9 -8
View File
@@ -170,14 +170,15 @@ var allowedRequestUses = map[string]string{
"functions in ONE file, so fourteen typed ops share one seam; all fail closed off the HTTP path — no " +
"request means the default project, no actor, and no audit record, since an unattributable record is " +
"worse than none, and tenantOf refuses before any of them is reached.",
"apps/team/typed.go": "tokenOf / sessionOf / admin / noStore / cookie — team authenticates its billing, " +
"files and collaborator planes with its OWN HS256 session token, which rides in Authorization or the " +
"HttpOnly account-token cookie; principal.OrgFrom carries neither (nor the WORKSPACE claim the " +
"collaborator plane gates on), and bots/sync additionally needs admin-ness (X-User-IsAdmin). The " +
"cookie WRITER is the other end of that same identity — the account-token cookie is set on the " +
"RESPONSE, which only the request reaches. It is ONE file for the whole subsystem on purpose — the " +
"resolvers live here so the planes that use them do not each reach for the request. All of them fail " +
"closed off the HTTP path: no request, no token, no identity, and no browser to sign out.",
"apps/team/typed.go": "callerOf / sessionOf / admin / noStore / cookie — team authenticates its billing, " +
"files and collaborator planes with a CALLER (identity.who): an IAM access token, else team's own " +
"HS256 session token, riding Authorization or an HttpOnly cookie. principal.OrgFrom carries none of " +
"those (nor the WORKSPACE an HS256 workspace token pins, which the collaborator plane gates on), and " +
"bots/sync additionally needs admin-ness (X-User-IsAdmin). The cookie WRITER is the other end of that " +
"same identity — the account-token cookie is set on the RESPONSE, which only the request reaches. It " +
"is ONE file for the whole subsystem on purpose — the resolvers live here so the planes that use them " +
"do not each reach for the request. All of them fail closed off the HTTP path: no request, no " +
"credential, no identity, and no browser to sign out.",
"apps/ml/typed.go": "tenantFrom — ml's tenant boundary is a per-org(+project) KUBERNETES NAMESPACE, and " +
"deriving it takes two facts principal.OrgFrom does not carry: the org SUB-SCOPE (X-Project-Id, " +
"which suffixes the namespace) and platform-admin-ness (X-User-IsAdmin, which buckets an org-less " +