Merge blue/team-fold (cloud): the team backend absorbs analytics collect and the office

Red-reviewed twice; cloud cleared to ship. 17/17 mutations killed.

/v1/event/collect carries the team SPA's analytics wire, which the canonical
decoder silently DROPPED — its keys are distinct_id/timestamp where the
canonical wire has distinctId/time, so identity and time vanished, Type ended up
empty, and admitPublic discarded the whole batch behind a 200
{accepted:0,dropped:N} the SPA's retry loop treats as success. Pinned by a test
of that old behaviour.

clients/meet replaces the team-love pod, minting LiveKit room tokens from the
same keys.yaml the LiveKit server validates against — read with yaml.v3 into
map[string]string, the library and target type LiveKit itself uses, because
sigs.k8s.io/yaml coerces 0123456789 to 1.2345679e+08 and yes to true.

The reduced principal is real: selectWorkspace had the invite role in hand and
was dropping it, so a guest got an owner-shaped token. It now signs extra.role,
token.Privileged() is the one predicate, and a guest writes into its OWN org
through the projection rather than being refused or filed under $public. Its
identity is the SIGNED account, not the body's distinct_id — a reduced principal
does not name the person; the token does.

error_message/error_type/error_stack now MOVE onto the typed Exception instead of
being copied, so a stack frame carrying a credential cannot reach the row or the
destinations fan-out around scrubException.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-07-28 02:51:32 -07:00
13 changed files with 2600 additions and 36 deletions
+9
View File
@@ -117,6 +117,7 @@ import (
"github.com/hanzoai/cloud/clients/link"
"github.com/hanzoai/cloud/clients/marketing"
"github.com/hanzoai/cloud/clients/marketplace"
"github.com/hanzoai/cloud/clients/meet"
"github.com/hanzoai/cloud/clients/ml"
"github.com/hanzoai/cloud/clients/notify"
// NOTE: clients/o11y is deliberately NOT imported. It is loaded at run time
@@ -438,6 +439,14 @@ func Wire() []cloud.MountSpec {
{Name: "cloudflare", Price: cloud.Metered, Mount: cloudflare.Mount},
{Name: "sbom", Price: cloud.Free, Mount: sbom.Mount, OwnsHealth: true},
{Name: "team", Price: cloud.Free, Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
// The virtual office's control plane (/v1/meet): mints a per-room LiveKit
// join token for a verified team member. Free like `team`, whose product it is
// part of — a call places one token mint, not a metered API call. Mounts after
// team because it verifies the caller with the SAME SERVER_SECRET that
// subsystem signs sessions with, and reads the role that subsystem signs. Media
// stays a direct browser<->LiveKit WebRTC connection; only the admission
// decision is in this binary. This retired the standalone team-love pod.
{Name: "meet", Price: cloud.Free, Mount: meet.Mount, OwnsHealth: true},
{Name: "settings", Price: cloud.Free, Mount: settings.Mount, Shutdown: settings.Shutdown},
{Name: "prefs", Price: cloud.Free, Mount: prefs.Mount, Shutdown: prefs.Shutdown},
{Name: "notify", Price: cloud.Free, Mount: notify.Mount, OwnsHealth: true},
+14 -8
View File
@@ -150,18 +150,24 @@ func TestAnonCommerce_RefusedOnBoundCustomDomain(t *testing.T) {
func TestAnonIdentity_RefusedAtEveryDoor(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, body := range []string{
`{"batch":[{"type":"identify","distinctId":"victim","personId":"victim-person"}]}`,
`{"batch":[{"type":"group","groupId":"victim-team"}]}`,
} {
for _, path := range doorPaths() {
code, got := doHost(t, app, path, "", "", "hanzo.ai", body)
// Each door is probed in ITS OWN wire (identifyFor/groupFor, doors_test.go). The
// bodies used to be two canonical-wire literals applied to every door, which only
// worked while every door spoke that wire: the team door accepts a bare ARRAY and
// answers an object body 400, so a shared literal measured decoder tolerance rather
// than the projection. 400 would satisfy this test's INTENT even more strictly than
// 200-all-dropped — nothing is stored either way — but "refused because the kind is
// not writable anonymously" and "refused because the body is the wrong shape" are
// different facts, and this test is about the first one.
for _, pick := range []func(*testing.T, door) string{identifyFor, groupFor} {
for _, d := range doors {
body := pick(t, d)
code, got := doHost(t, app, d.path, "", "", "hanzo.ai", body)
if code != http.StatusOK {
t.Errorf("anonymous %s on %s = %d (%s), want 200 all-dropped", body, path, code, got)
t.Errorf("anonymous %s on %s = %d (%s), want 200 all-dropped", body, d.path, code, got)
continue
}
if r := receipt(t, got); r.Accepted != 0 || r.Dropped != 1 {
t.Errorf("anonymous %s on %s receipt = %+v, want accepted:0 dropped:1", body, path, r)
t.Errorf("anonymous %s on %s receipt = %+v, want accepted:0 dropped:1", body, d.path, r)
}
}
}
+30 -2
View File
@@ -50,6 +50,7 @@ var wantDoors = []door{
{path: "/v1/analytics", decode: decodeIngest, source: sourceCapture},
{path: "/v1/analytics/batch", decode: decodeIngest, source: sourceCapture},
{path: "/v1/tracker", decode: decodeIngest, source: sourceCapture},
{path: "/v1/event/collect", decode: decodeTeam, source: sourceTeam},
}
// samePtr reports whether two func values are the SAME function, by code pointer.
@@ -278,14 +279,41 @@ const (
posthogPage = `{"event":"$pageview","distinct_id":"anon-1","properties":{"$pathname":"/pricing"}}`
canonCommerce = `{"batch":[{"type":"event","event":"order_completed","revenue":999,"groupId":"victim","personId":"victim-person"}]}`
posthogEvent = `{"event":"order_completed","distinct_id":"d","properties":{"revenue":999}}`
// The Hanzo Team SPA wire: a BARE ARRAY, epoch-millis timestamp, snake_case
// distinct_id. navigation folds to the pageview kind (admitted anonymously);
// customEvent folds to the bare `event` kind (dropped), which is what makes the
// capability assertions on this door mean something rather than just reachability.
teamPageview = `[{"event":"navigation","properties":{"path":"/pricing"},"timestamp":1750000000000,"distinct_id":"u"}]`
teamCommerce = `[{"event":"customEvent","properties":{"event":"order_completed","revenue":999},"timestamp":1750000000000,"distinct_id":"u"}]`
// The person- and group-BINDING kinds, per wire. These are the two an anonymous
// caller must never store (publicKinds admits pageview and error only), and the
// kind is expressed differently in each wire — so the door's own wire has to be
// used, or the assertion tests the DECODER's tolerance instead of the projection.
canonIdentify = `{"batch":[{"type":"identify","distinctId":"victim","personId":"victim-person"}]}`
canonGroup = `{"batch":[{"type":"group","groupId":"victim-team"}]}`
posthogIdentify = `{"event":"$identify","distinct_id":"victim","properties":{}}`
posthogGroup = `{"event":"$groupidentify","distinct_id":"victim","properties":{}}`
teamIdentify = `[{"event":"setUser","properties":{},"timestamp":1750000000000,"distinct_id":"victim"}]`
teamGroup = `[{"event":"setGroup","properties":{},"timestamp":1750000000000,"distinct_id":"victim"}]`
)
// identifyFor / groupFor give the door its OWN wire's person- / group-binding event,
// picking whichever candidate that door's decoder accepts and the projection refuses.
func identifyFor(t *testing.T, d door) string {
return droppedWire(t, d, canonIdentify, posthogIdentify, teamIdentify)
}
func groupFor(t *testing.T, d door) string {
return droppedWire(t, d, canonGroup, posthogGroup, teamGroup)
}
func pageviewFor(t *testing.T, d door) string {
return admittedWire(t, d, canonPageview, posthogPage)
return admittedWire(t, d, canonPageview, posthogPage, teamPageview)
}
func commerceFor(t *testing.T, d door) string {
return droppedWire(t, d, canonCommerce, posthogEvent)
return droppedWire(t, d, canonCommerce, posthogEvent, teamCommerce)
}
// ── the surface is one set ──────────────────────────────────────────────────
+109 -21
View File
@@ -95,23 +95,52 @@ func (e Event) toCapture() CaptureEvent {
}
}
// eventTenant resolves the tenant for the canonical door — PLUGGABLE auth,
// FAIL-CLOSED, in strict trust order:
// admission is a RESOLVED credential: the org it names and the capability it carries.
// Capability is a property of the CREDENTIAL, which is why it lives here and not on a
// door — a door still cannot ask for anything. Two levels, because the platform mints
// two kinds of principal:
//
// 1. a validated IAM bearer principal wins (its owner org);
// 2. else a presented write-only publishable key (pk_…) is HMAC-verified to its
// org with no IAM/DB hop (the SAME verifier publishable.go's /v1/ingest used —
// folded in here so a pk_ caller uses /v1/event directly);
// full ⇒ the unprojected write into org. Every API credential, and a workspace
// member's session.
// !full ⇒ the PROJECTED write into org (publicIngest with org as the tenant — the
// same lane the site-host carve uses). A reduced principal: it has proven
// which org it belongs to, so its pageviews and errors belong there, but it
// may not write the revenue/personId/groupId columns or an arbitrary event
// kind.
//
// The middle level is not a nicety. Without it a guest is either trusted with the
// whole custom product/billing surface of an org it was invited into for one channel,
// or has its telemetry filed under $public where that org cannot read it. Both are
// wrong, and the projection is exactly the shape that is right.
type admission struct {
org string
full bool
// subject is the credential's OWN signed identity. It is only consulted on the
// reduced lane, where it REPLACES the caller-supplied distinctId — see handle. It
// is empty for the full-capability credentials, which are trusted to attribute
// their own writes.
subject string
}
// eventTenant resolves the credential for every door — PLUGGABLE auth, FAIL-CLOSED,
// in strict trust order:
//
// 1. a validated IAM bearer principal wins (its owner org), at FULL capability;
// 2. else a presented write-only publishable key (pk_…) is HMAC-verified to its org
// with no IAM/DB hop (the SAME verifier publishable.go's /v1/ingest used — folded
// in here so a pk_ caller uses /v1/event directly), at FULL capability;
// 3. else a presented out-of-band IAM access key (hk-/sk-…) is resolved to its org
// through the ONE key seam (resolveKeyOrg → cloud.OrgForKey).
// through the ONE key seam (resolveKeyOrg → cloud.OrgForKey), at FULL capability;
// 4. else a verified Hanzo Team workspace token — at FULL capability for a member,
// and at REDUCED capability for a guest (teamTenant, team.go).
//
// None matches ⇒ ("", false), which handle answers by refusing a presented-but-
// unresolvable credential and otherwise taking the anonymous lane. There is NO
// host fallback on ANY door, so a full-capability tenant is only ever IAM or a
// signed/resolvable key — never the request Host.
func eventTenant(c *zip.Ctx) (string, bool) {
// None matches ⇒ (admission{}, false), which handle answers by refusing a presented-
// but-unresolvable credential and otherwise taking the anonymous lane. There is NO
// host fallback on ANY door, so a tenant is only ever IAM, a signed/resolvable key, or
// a signed team claim — never the request Host.
func eventTenant(c *zip.Ctx) (admission, bool) {
if org, ok := tenant(c); ok {
return org, true
return admission{org: org, full: true}, true
}
// ONE publishable key, and IAM issues it. A pk- on any ingest-shaped carrier
// (Bearer, x-hanzo-ingest-key, ?ingest_key= for sendBeacon, which cannot set
@@ -124,15 +153,29 @@ func eventTenant(c *zip.Ctx) (string, bool) {
// refuses it, so it attributes a write and never mints a reading principal.
if key := ingestKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return org, true
return admission{org: org, full: true}, true
}
}
if key := projectKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return org, true
return admission{org: org, full: true}, true
}
}
return "", false
// A Hanzo Team workspace token (HS256 over SERVER_SECRET, org and role in the
// signed extra) — the credential the team SPA already holds. It is a PLATFORM
// credential, so it belongs in the trust order rather than on the door that
// happens to need it, and it therefore works on every door (team.go). It is the
// ONLY entry that can resolve at reduced capability, because it is the only one
// the platform issues to a principal weaker than "holds an API key".
//
// It is LAST because it is the narrowest: the other three are issued to BE API
// credentials, while this one is a browser session a user's tab carries. Ordering
// it after them means a request holding both is attributed to the deliberate API
// credential, never to whatever tab it came from.
if a, ok := teamTenant(c); ok {
return a, true
}
return admission{}, false
}
// firstNonWS returns the index of the first non-JSON-whitespace byte, or len(body)
@@ -246,12 +289,33 @@ func ingestDecoded(c *zip.Ctx, org, source string, evs []CaptureEvent, dropped i
return c.JSON(http.StatusOK, res)
}
// presented reports whether the request PRESENTED an ingest credential at all,
// presented reports whether the request PRESENTED an IDENTIFIABLE credential at all,
// independent of whether it resolved. It is the discriminator between "misconfigured"
// (refuse) and "anonymous" (project), and it names exactly the carriers eventTenant
// consults for a key, so the two can never disagree about what "presented" means.
// consults, so the two can never disagree about what "presented" means. When
// eventTenant learned about team tokens and this did not, they DID disagree, and the
// result was the precise failure the team door exists to prevent: an expired team
// token answered 200 with its rows filed under $public, a partition its org cannot
// read.
//
// WHY A KEY AND A TEAM TOKEN REFUSE, AND A STALE IAM BEARER DOES NOT. The asymmetry is
// a fact about what is DECIDABLE, not a preference:
//
// - an ingest key is self-identifying by PREFIX (pk-/hk-/sk-), and a team token is
// self-identifying by STRUCTURE (it carries an `account` claim, which an IAM token
// does not). For both, "the caller presented THIS kind of credential" is answerable
// without trusting anything, so a failure to resolve is unambiguously a
// misconfiguration and 403 is the honest answer.
// - an arbitrary `Authorization: Bearer <jwt>` is not distinguishable from a bearer
// minted for some other audience entirely. IdentityMiddleware already declines to
// 401 it (validatedPrincipal returns nil rather than refusing), so treating its
// mere presence as "presented" here would turn every stale or foreign bearer that
// reaches an ingest door into a 403 — a refusal on evidence we do not have.
//
// So: identifiable credential that fails ⇒ 403. Unidentifiable bearer ⇒ the anonymous
// lane, exactly as before this file learned about team tokens.
func presented(c *zip.Ctx) bool {
return ingestKey(c) != "" || projectKey(c) != ""
return ingestKey(c) != "" || projectKey(c) != "" || teamPresented(c)
}
// handle is THE ingest pipeline and the ONE place in this package where trust level is
@@ -275,12 +339,29 @@ func presented(c *zip.Ctx) bool {
// because the functions that used to take an org and write at full capability
// (ingestBody / eventWithOrg / captureWithOrg / insightsWithOrg) no longer exist.
func handle(c *zip.Ctx, dec decode, source string) error {
if org, ok := eventTenant(c); ok {
if a, ok := eventTenant(c); ok {
if !a.full {
// A REDUCED principal: it proved WHICH org it belongs to, so its rows land
// in that org — but through the projection, so it cannot write revenue,
// personId, groupId or an arbitrary event kind into a tenant it was
// invited into for one channel.
//
// AND IT DOES NOT NAME THE PERSON. The projection was designed for an
// ANONYMOUS caller writing to $public, where a forged distinctId is
// meaningless. Aimed at a REAL org the same field changes meaning: it is
// the join key every person-level lens groups by, and the team SPA puts the
// account identifier there, so a guest could attribute pageviews and errors
// to a named colleague inside the host org. The projection cannot strip it —
// it is what makes the lane useful — so the fix is to stop taking it from
// the caller: on this lane the SIGNED account is the identity. A reduced
// principal does not name the person; its token does.
return publicIngest(c, dec, a.org, source, a.subject)
}
evs, err := dec(c.Body())
if err != nil {
return zip.ErrBadRequest("malformed event payload")
}
return ingestDecoded(c, org, source, evs, 0)
return ingestDecoded(c, a.org, source, evs, 0)
}
if presented(c) {
return zip.ErrForbidden("valid bearer or a resolvable ingest key required")
@@ -355,6 +436,13 @@ var doors = []door{
{path: "/v1/analytics", decode: decodeIngest, source: sourceCapture},
{path: "/v1/analytics/batch", decode: decodeIngest, source: sourceCapture},
{path: "/v1/tracker", decode: decodeIngest, source: sourceCapture},
// The Hanzo Team SPA's wire. A THIRD wire, in the same sense /v1/insights/e is a
// second one: the SPA is a published bundle that POSTs a bare array of
// {event, properties, timestamp(ms), distinct_id}, which decodeIngest ACCEPTS and
// then drops whole (canonicalType("") is "event", not in publicKinds). The
// /collect suffix is the caller's — it appends it to ANALYTICS_COLLECTOR_URL.
// This retired the standalone team-analytics pod.
{path: "/v1/event/collect", decode: decodeTeam, source: sourceTeam},
}
// ingest is the door's API-host handler: admission (handle) over the door's wire.
+31 -1
View File
@@ -269,6 +269,28 @@ func admitPublic(evs []CaptureEvent) ([]CaptureEvent, int) {
return out, dropped
}
// attribute stamps the SIGNED identity onto every admitted row, replacing whatever the
// caller sent. It runs AFTER admitPublic so the projection still decides which rows
// exist; this only decides who they belong to.
//
// DistinctID is the join key every person-level lens groups by, and AnonymousID is the
// pre-login alias that stitches to it — both are caller-supplied, so on a lane where a
// real org will read the rows, both have to come from the token instead. AnonymousID is
// CLEARED rather than overwritten: it exists to link an anonymous session to a person
// later, and there is nothing to link when the person is already known.
//
// Not addressed here, and named rather than hidden: Timestamp is still the caller's.
// clampTS only pulls the FUTURE back to now, so a reduced principal can back-date its
// own events within its own org. Clamping the past would break the SPA's legitimate
// batching and its retry queue, which is why it is left alone.
func attribute(evs []CaptureEvent, subject string) []CaptureEvent {
for i := range evs {
evs[i].DistinctID = subject
evs[i].AnonymousID = ""
}
return evs
}
// publicIngest answers a CREDENTIAL-LESS POST on any door: the request-scoped gates
// (capture flag, rate, size, opt-out) then the pure decision (admitPublic) then the ONE
// write core. dec is the door's wire; source stays the door's origin tag.
@@ -282,7 +304,12 @@ func admitPublic(evs []CaptureEvent) ([]CaptureEvent, int) {
// presented-but-unresolvable key is refused there rather than downgraded.
// - the site-host carve reaches here unconditionally, because it runs BEFORE the
// identity boundary and so has no credential it could trust (see event.go).
func publicIngest(c *zip.Ctx, dec decode, org, source string) error {
//
// subject, when non-empty, is the credential's OWN signed identity and REPLACES the
// caller-supplied one on every admitted row (see handle). It is variadic so the two
// genuinely anonymous callers — the credential-less lane and the site-host carve — stay
// exactly as they were: nobody signed for them, so there is no identity to substitute.
func publicIngest(c *zip.Ctx, dec decode, org, source string, subject ...string) error {
// CLOUD_ANALYTICS_PUBLIC_CAPTURE is the ONE existing anonymous-capture switch
// (it also gates the site-host carve). Off ⇒ the canonical door keeps its
// strict, principal-only contract.
@@ -309,5 +336,8 @@ func publicIngest(c *zip.Ctx, dec decode, org, source string) error {
// Rejoin the ONE pipeline: admission decided the projection, the door decided the
// tenant, and ingestDecoded (event.go) does the rest exactly as it does for a bearer.
admitted, dropped := admitPublic(evs)
if len(subject) > 0 && subject[0] != "" {
admitted = attribute(admitted, subject[0])
}
return ingestDecoded(c, org, source, admitted, dropped)
}
+307
View File
@@ -0,0 +1,307 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// team.go — the Hanzo Team SPA's ingest WIRE, and the team session token as an
// ingest CREDENTIAL. Two independent things, which is why they are two functions:
//
// POST /v1/event/collect body: [TeamEvent] -> {accepted, dropped}
//
// THE WIRE. The team SPA is a PUBLISHED bundle (ghcr.io/hanzoai/front), so its
// emitter is a caller fact we adapt to, not a design we choose. It POSTs a BARE
// JSON ARRAY of {event, properties, timestamp, distinct_id} where `event` is a
// closed 7-member enum, `timestamp` is epoch MILLIS as a NUMBER, and the person id
// is snake_case `distinct_id`. That is a second WIRE in the exact sense
// /v1/insights/e is one — the canonical decoder cannot serve it:
//
// decodeIngest sees the leading '[' and decodes []Event, whose fields are
// `distinctId` and `time`. Neither key is present, so DistinctID and Time come
// back EMPTY and Type is left empty by toCapture. canonicalType("") is "event",
// which is NOT in publicKinds — so on the anonymous lane admitPublic drops the
// batch WHOLE and the caller gets 200 {"accepted":0,"dropped":N}.
//
// A silent 200 that stores nothing is strictly worse than a 4xx: the SPA's retry
// loop sees ok and discards, and the error the user hit is gone. decodeTeam exists
// so that cannot happen — it names the kind, so the events survive admission.
//
// THE CREDENTIAL is separate and lives in eventTenant with the other three, NOT on
// this door. Trust level is decided once, in handle, for every door (event.go); a
// door that resolved its own tenant would be the drift that design exists to
// prevent. A team session token is a platform credential, so it works on the
// canonical door too — that is the point, not a side effect.
package analytics
import (
"encoding/json"
"os"
"strings"
"time"
"github.com/hanzoai/cloud/clients/team/token"
"github.com/zap-proto/zip"
)
// sourceTeam tags every row that arrived on the team SPA's door, so "is the team
// pipe live" is a warehouse query (properties.$source = 'team') and not a guess —
// the same closing signal the sunsetting aliases carry.
const sourceTeam = "team"
// teamEvent is ONE element of the team SPA's wire. Four fields; a retried batch also
// carries a top-level `retryCount`, which is ignored here exactly as encoding/json
// ignores any unknown field — the retry counter is the SPA's bookkeeping, not ours.
type teamEvent struct {
Event string `json:"event"` // AnalyticEventType, a closed 7-member enum
Properties map[string]any `json:"properties"` // event metadata; error_* on an error
Timestamp int64 `json:"timestamp"` // epoch MILLIS (Date.now()), not RFC3339
DistinctID string `json:"distinct_id"` // snake_case, unlike the canonical wire
}
// teamKind folds the SPA's event enum onto canonicalType's closed set. The mapping is
// total, so no member is silently dropped, and the two members that decide whether the
// pipe works at all under an anonymous caller — error and navigation — land on the two
// kinds publicKinds admits:
//
// error -> error the window/unhandledrejection capture; the whole reason
// this pipe exists. Kept first-class so /v1/errors sees it.
// navigation -> pageview a route change is a pageview.
// setUser -> identify binds properties to a person …
// setTag -> identify … so does a person property …
// setAlias -> identify … so does binding an anonymous id to that person.
// setGroup -> group binds the person to a workspace.
// customEvent -> event the open-ended surface; its name rides in properties.
//
// An enum member a NEWER SPA adds also folds to "event" rather than erroring, and
// keeps its raw name (teamName) — a new event kind lands, tagged as what it called
// itself, instead of 400-ing a whole batch of otherwise-good events.
func teamKind(event string) string {
switch event {
case "error":
return "error"
case "navigation":
return "pageview"
case "setUser", "setTag", "setAlias":
return "identify"
case "setGroup":
return "group"
default: // customEvent, and any member added after this was written
return "event"
}
}
// teamName is the stored event name for kind "event". The SPA puts the human name in
// properties.event and leaves the top-level `event` as the enum tag "customEvent", so
// the name has to be lifted out; an unrecognized enum member has no properties.event
// and keeps its own tag. Every other kind takes its name from resolveEventName
// ($pageview / $identify / $group / $error), which is why this returns "" for them.
func teamName(kind string, e teamEvent) string {
if kind != "event" {
return ""
}
if s, ok := e.Properties["event"].(string); ok {
if s = strings.TrimSpace(s); s != "" {
return s
}
}
return strings.TrimSpace(e.Event)
}
// teamString reads a string property and REMOVES it from the map. Removal is the
// point, not a convenience: the three error_* properties are re-homed onto the typed
// Exception so foldException runs them through scrubException. A copy left behind in
// Properties would be stored raw AND handed raw to the destinations fan-out (forward.go
// sees events before the warehouse scrub) — which is precisely the token/PII leak out
// of a stack frame that the scrubber exists to stop.
func teamString(props map[string]any, key string) string {
s, _ := props[key].(string)
delete(props, key)
return strings.TrimSpace(s)
}
// teamException lifts the SPA's flat error_* properties onto the typed Exception the
// ONE pipeline already understands. ingestDecoded folds it into properties.$exception
// (scrubbed) for every lane, so a team error is stored in the SAME shape as an error
// from @hanzo/event and the /v1/errors lens needs no team-specific branch.
func teamException(props map[string]any) *Exception {
ex := Exception{
Message: teamString(props, "error_message"),
Type: teamString(props, "error_type"),
Stack: teamString(props, "error_stack"),
}
if ex.Message == "" && ex.Type == "" && ex.Stack == "" {
return nil
}
if ex.Message == "" {
ex.Message = "Unknown error"
}
return &ex
}
// teamTime converts the SPA's epoch-millis number to the RFC3339 string the write core
// parses. A zero/absent timestamp stays EMPTY so clampTS anchors it to server-now,
// which is the same honest default every other wire gets — never 1970.
func teamTime(ms int64) string {
if ms <= 0 {
return ""
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
// decodeTeam is the team SPA's wire decoder: the bare JSON array it POSTs → the
// canonical []CaptureEvent the ONE write core consumes. A non-array body is an error
// rather than a best-effort guess — the SPA emits an array unconditionally, so anything
// else is a misconfigured caller and an honest 400 beats a silent empty receipt. An
// empty/whitespace-only body yields no events (an honest empty receipt, not an error),
// matching decodeIngest.
func decodeTeam(body []byte) ([]CaptureEvent, error) {
i := firstNonWS(body)
if i >= len(body) {
return nil, nil
}
var raw []teamEvent
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
out := make([]CaptureEvent, len(raw))
for j, e := range raw {
if e.Properties == nil {
e.Properties = map[string]any{}
}
kind := teamKind(e.Event)
ev := CaptureEvent{
Type: kind,
Event: teamName(kind, e),
Timestamp: teamTime(e.Timestamp),
DistinctID: e.DistinctID,
AnonymousID: anyString(e.Properties["$anonymous_id"]),
Properties: e.Properties,
}
if kind == "error" {
ev.Error = teamException(e.Properties)
}
out[j] = ev
}
return out, nil
}
// anyString is the nil-safe string read for an untyped property value.
func anyString(v any) string {
s, _ := v.(string)
return strings.TrimSpace(s)
}
// ── the team session token as an ingest credential ───────────────────────────
// teamSecretEnv is the HS256 session-signing key clients/team signs team tokens with.
// ONE env var, ONE secret, read here the SAME way team.go reads it — this package
// verifies what that one signs, it does not own a second key.
const teamSecretEnv = "SERVER_SECRET"
// teamSecret returns the signing key, or "" when there is none to trust. It refuses
// the upstream public default literal for the same reason team.go's resolveSecret
// fail-closes on it: with a known key, ANY caller can mint {extra:{org:"victim"}} and
// write into a tenant it has no claim to. No secret ⇒ no team credential ⇒ the caller
// takes the anonymous lane. Fail-closed, and the closed state is still useful.
func teamSecret() string {
s := os.Getenv(teamSecretEnv)
if s == "secret" {
return ""
}
return s
}
// teamTenant resolves a Hanzo Team workspace token to the org it names AND the
// capability it carries. It is the fourth entry in eventTenant's trust order and
// behaves like the other three: verified SERVER-SIDE, fail-closed, and the org comes
// from the SIGNED claim — never the body, never the Host.
//
// token.Decode with verify=true checks the HMAC, exp and nbf, so an expired or forged
// token resolves to nothing (and, because teamPresented names it, is REFUSED rather
// than downgraded).
//
// CAPABILITY comes from the signed extra.role via the ONE predicate that reads it,
// token.Privileged: a member writes unprojected, a guest writes PROJECTED into the
// same org. This replaced a pair of string comparisons against extra.guest /
// extra.readonly that were ported from upstream's hasWorkspaceAccess and were INERT
// here — nothing in this repo has ever minted those claims, because upstream sets them
// on guest-LINK tokens, a path this port does not have. The real reduced principal is
// the workspace role, which selectWorkspace now signs. A guard that cannot fire is
// worse than no guard: it reads as protection while a guest holds an owner-shaped
// token.
func teamTenant(c *zip.Ctx) (admission, bool) {
t, ok := verifyTeam(c)
if !ok {
return admission{}, false
}
org := t.Org()
if org == "" {
// A verified token with no tenant names nothing to write into. Refused rather
// than admitted with org="", which normalizeEvent would happily store as the
// tenant column and fanOut would forward under an empty org.
return admission{}, false
}
// subject is the SIGNED account uuid. On the reduced lane it replaces whatever
// distinct_id the body carried, so a guest attributes its own activity and cannot
// attribute it to a colleague.
return admission{org: org, full: t.Privileged(), subject: t.Account}, true
}
// verifyTeam VERIFIES the request's bearer as a team token and returns it. The one
// place this package validates a team credential, so "verified" cannot drift from
// "used".
func verifyTeam(c *zip.Ctx) (*token.Token, bool) {
secret := teamSecret()
if secret == "" {
return nil, false
}
raw := teamBearer(c.Header("Authorization"))
if raw == "" {
return nil, false
}
t, err := token.Decode(raw, secret, true)
if err != nil {
return nil, false
}
return t, true
}
// teamPresented reports whether the caller presented something SHAPED like a team
// token, without verifying it and without trusting a byte of it. It is what lets
// presented() (event.go) refuse an expired or forged team token with 403 instead of
// silently filing its rows under $public.
//
// The discriminator is the `account` claim: token.Generate requires it and an IAM
// access token does not carry it, so this identifies the credential FAMILY without
// deciding anything about its validity. Decoding with verify=false is safe precisely
// because the answer is never used as authorization — only to choose between "refuse"
// and "project".
func teamPresented(c *zip.Ctx) bool {
raw := teamBearer(c.Header("Authorization"))
if raw == "" {
return false
}
t, err := token.Decode(raw, "", false)
if err != nil {
return false
}
return strings.TrimSpace(t.Account) != ""
}
// teamBearer extracts the token from an "Authorization: Bearer <t>" header (scheme
// case-insensitive). Empty when absent or not a bearer.
func teamBearer(h string) string {
h = strings.TrimSpace(h)
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
return strings.TrimSpace(h[7:])
}
return ""
}
+745
View File
@@ -0,0 +1,745 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud/clients/team/token"
"github.com/zap-proto/zip"
)
// teamAccount is a syntactically valid account UUID — token.Generate requires one.
const teamAccount = "550e8400-e29b-41d4-a716-446655440000"
// teamWire is a VERBATIM batch as the published team SPA emits it
// (packages/analytics-providers/src/analyticsCollector.ts): a bare JSON array whose
// elements carry the enum tag in `event`, epoch MILLIS in `timestamp`, and the person
// id under the snake_case `distinct_id`. Every assertion below runs against this exact
// shape, so a test passing here is a statement about the real caller and not about a
// payload invented to fit the decoder.
const teamWire = `[
{"event":"error","properties":{"error_message":"boom","error_type":"TypeError","error_stack":"at f (app.js:1)\nBearer sk-live-DEADBEEF","analytics_collector":true,"$anonymous_id":"anon_1"},"timestamp":1750000000000,"distinct_id":"user@hanzo.ai"},
{"event":"navigation","properties":{"path":"/tracker"},"timestamp":1750000001000,"distinct_id":"user@hanzo.ai"}
]`
// postBody issues a POST with a raw body and optional Authorization header.
func postBody(t *testing.T, app *zip.App, path, body, auth string) (int, CaptureResult) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if auth != "" {
req.Header.Set("Authorization", "Bearer "+auth)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
var res CaptureResult
_ = json.Unmarshal(b, &res)
return resp.StatusCode, res
}
// ── the evidence for the door ────────────────────────────────────────────────
// TestCanonicalWireSilentlyDropsTeamBatch is THE reason /v1/event/collect exists, and
// it is a test of the OLD behavior, not the new: it pins what a naive repoint of
// ANALYTICS_COLLECTOR_URL at the canonical door would have done.
//
// decodeIngest sees the leading '[' and decodes []Event, whose keys are `distinctId`
// and `time`. The team wire has neither, so the person id and the timestamp are lost,
// and toCapture leaves Type empty. canonicalType("") is "event", which is not in
// publicKinds — so admitPublic drops EVERY event. Accepted 0, dropped 2.
//
// That is the accepted-then-dropped failure: the SPA would see a 200 and discard its
// retry queue while nothing was ever stored.
func TestCanonicalWireSilentlyDropsTeamBatch(t *testing.T) {
evs, err := decodeIngest([]byte(teamWire))
if err != nil {
t.Fatalf("decodeIngest: %v", err)
}
if len(evs) != 2 {
t.Fatalf("decodeIngest events = %d, want 2", len(evs))
}
// The two fields the canonical wire cannot see.
if evs[0].DistinctID != "" {
t.Errorf("canonical decode DistinctID = %q, want empty (key is distinct_id, not distinctId)", evs[0].DistinctID)
}
if evs[0].Timestamp != "" {
t.Errorf("canonical decode Timestamp = %q, want empty (key is timestamp:number, not time:string)", evs[0].Timestamp)
}
admitted, dropped := admitPublic(evs)
if len(admitted) != 0 || dropped != 2 {
t.Fatalf("canonical wire admitted %d dropped %d, want 0 admitted / 2 dropped", len(admitted), dropped)
}
}
// TestTeamWireLands is the other half: the SAME bytes through the team door survive
// admission AND normalize into real rows. It walks the WHOLE pipeline offline —
// decode, the anonymous projection, the exception fold, then normalizeEvent, which is
// the last function before the INSERT. A row out of normalizeEvent with ok==true is
// what "the event landed" means everywhere else in this package.
func TestTeamWireLands(t *testing.T) {
evs, err := decodeTeam([]byte(teamWire))
if err != nil {
t.Fatalf("decodeTeam: %v", err)
}
admitted, dropped := admitPublic(evs)
if len(admitted) != 2 || dropped != 0 {
t.Fatalf("team wire admitted %d dropped %d, want 2 admitted / 0 dropped", len(admitted), dropped)
}
now := time.Now().UTC()
want := []struct{ name, kind string }{{"$error", "error"}, {"$pageview", "pageview"}}
for i, ev := range admitted {
row, ok := normalizeEvent("acme", now, foldException(ev))
if !ok {
t.Fatalf("event %d did not normalize into a row", i)
}
if row.event != want[i].name || row.eventType != want[i].kind {
t.Errorf("event %d = (%s,%s), want (%s,%s)", i, row.event, row.eventType, want[i].name, want[i].kind)
}
if row.tenant != "acme" {
t.Errorf("event %d tenant = %q, want acme", i, row.tenant)
}
}
}
// TestTeamWireKeepsIdentityOnFullLane: on the vouched-for lane there is no projection,
// so the two fields the canonical wire lost must both survive — the snake_case person
// id and the epoch-millis timestamp converted to the instant the SPA meant.
func TestTeamWireKeepsIdentityOnFullLane(t *testing.T) {
evs, err := decodeTeam([]byte(teamWire))
if err != nil {
t.Fatalf("decodeTeam: %v", err)
}
row, ok := normalizeEvent("acme", time.Now().UTC(), foldException(evs[0]))
if !ok {
t.Fatal("did not normalize")
}
if row.distinctID != "user@hanzo.ai" {
t.Errorf("distinctID = %q, want user@hanzo.ai", row.distinctID)
}
if row.anonymousID != "anon_1" {
t.Errorf("anonymousID = %q, want anon_1", row.anonymousID)
}
if got := row.timestamp.UTC(); !got.Equal(time.UnixMilli(1750000000000).UTC()) {
t.Errorf("timestamp = %s, want %s", got, time.UnixMilli(1750000000000).UTC())
}
}
// ── the wire ─────────────────────────────────────────────────────────────────
// TestTeamKindMapping pins the whole 7-member enum onto canonicalType's closed set.
// The mapping is TOTAL by design: a member that fell through to a kind outside
// publicKinds would be dropped for an anonymous caller, which is the failure this
// door exists to prevent, so every member is asserted rather than the two that matter
// most.
func TestTeamKindMapping(t *testing.T) {
cases := []struct{ event, kind, name string }{
{"error", "error", ""},
{"navigation", "pageview", ""},
{"setUser", "identify", ""},
{"setTag", "identify", ""},
{"setAlias", "identify", ""},
{"setGroup", "group", ""},
{"customEvent", "event", "checkout_started"},
// A member added by a NEWER SPA still lands, keeping its own tag as the name.
{"somethingNew", "event", "somethingNew"},
}
for _, c := range cases {
body := `[{"event":"` + c.event + `","properties":{"event":"checkout_started"},"timestamp":1750000000000,"distinct_id":"u"}]`
if c.event == "somethingNew" {
body = `[{"event":"somethingNew","properties":{},"timestamp":1750000000000,"distinct_id":"u"}]`
}
evs, err := decodeTeam([]byte(body))
if err != nil {
t.Fatalf("%s: decodeTeam: %v", c.event, err)
}
if evs[0].Type != c.kind {
t.Errorf("%s -> kind %q, want %q", c.event, evs[0].Type, c.kind)
}
if evs[0].Event != c.name {
t.Errorf("%s -> name %q, want %q", c.event, evs[0].Event, c.name)
}
// Whatever the kind, it must produce a storable row.
if _, ok := normalizeEvent("acme", time.Now().UTC(), foldException(evs[0])); !ok {
t.Errorf("%s did not normalize into a row", c.event)
}
}
}
// TestTeamErrorPropertiesAreRehomed is a SECURITY assertion, not a formatting one.
// foldException scrubs the typed Exception into properties.$exception precisely so a
// token or PII lifted from a stack frame never reaches the row or the destinations
// fan-out. A copy of the stack left behind under error_stack would route around that
// scrubber entirely, so the three error_* properties must MOVE, not be copied.
func TestTeamErrorPropertiesAreRehomed(t *testing.T) {
evs, err := decodeTeam([]byte(teamWire))
if err != nil {
t.Fatalf("decodeTeam: %v", err)
}
e := evs[0]
if e.Error == nil {
t.Fatal("error event has no typed Exception")
}
if e.Error.Message != "boom" || e.Error.Type != "TypeError" || !strings.Contains(e.Error.Stack, "app.js:1") {
t.Errorf("Exception = %+v, want the error_* values", *e.Error)
}
for _, k := range []string{"error_message", "error_type", "error_stack"} {
if _, still := e.Properties[k]; still {
t.Errorf("property %q survived on Properties — it bypasses scrubException", k)
}
}
// The unrelated property is untouched: this moves three keys, it does not filter.
if e.Properties["analytics_collector"] != true {
t.Error("decodeTeam dropped an unrelated property")
}
// End to end: the secret in the stack must not appear raw in the stored row.
row, ok := normalizeEvent("acme", time.Now().UTC(), foldException(e))
if !ok {
t.Fatal("did not normalize")
}
if strings.Contains(row.properties, "sk-live-DEADBEEF") {
t.Errorf("raw secret from the stack reached the stored properties: %s", row.properties)
}
}
// TestTeamTimestampAbsentClampsToNow: a missing/zero timestamp must stay EMPTY out of
// the decoder so clampTS anchors it to server-now — never to the Unix epoch, which
// would file every such event in 1970 and silently skew every window query.
func TestTeamTimestampAbsentClampsToNow(t *testing.T) {
evs, err := decodeTeam([]byte(`[{"event":"error","properties":{"error_message":"x"},"distinct_id":"u"}]`))
if err != nil {
t.Fatalf("decodeTeam: %v", err)
}
if evs[0].Timestamp != "" {
t.Fatalf("absent timestamp decoded to %q, want empty", evs[0].Timestamp)
}
now := time.Now().UTC()
row, _ := normalizeEvent("acme", now, foldException(evs[0]))
if row.timestamp.Before(now.Add(-time.Minute)) {
t.Errorf("absent timestamp stored as %s, want ~now", row.timestamp)
}
}
// TestTeamRetriedBatchDecodes: handleFailedEvents mutates the queued event in place and
// re-serializes it, so a RETRIED request carries an extra top-level retryCount. A
// retry is exactly when the payload matters most; it must not become a 400.
func TestTeamRetriedBatchDecodes(t *testing.T) {
body := `[{"event":"error","properties":{"error_message":"boom"},"timestamp":1750000000000,"distinct_id":"u","retryCount":2}]`
evs, err := decodeTeam([]byte(body))
if err != nil {
t.Fatalf("retried batch: %v", err)
}
if len(evs) != 1 || evs[0].Type != "error" {
t.Fatalf("retried batch decoded to %+v", evs)
}
}
// TestTeamNonArrayRefused: the SPA emits an array unconditionally, so anything else is
// a misconfigured caller. An honest 400 beats an empty receipt that reads like success.
func TestTeamNonArrayRefused(t *testing.T) {
if _, err := decodeTeam([]byte(`{"event":"error"}`)); err == nil {
t.Error("an object body decoded without error; want a decode failure -> 400")
}
// An empty body is an honest empty receipt, not an error (matches decodeIngest).
if evs, err := decodeTeam([]byte(" \n")); err != nil || len(evs) != 0 {
t.Errorf("empty body = (%v, %v), want (no events, no error)", evs, err)
}
}
// ── the credential ───────────────────────────────────────────────────────────
func teamToken(t *testing.T, org, secret string, extra map[string]any, exp int64) string {
t.Helper()
// role defaults to member — the ordinary caller selectWorkspace mints. Pass
// extra{"role":"guest"} for a guest, extra{"role":""} for a token that never
// proved a role.
e := map[string]any{"org": org, "role": token.RoleMember}
for k, v := range extra {
e[k] = v
}
tok, err := token.Generate(teamAccount, "", e, exp, secret)
if err != nil {
t.Fatalf("token.Generate: %v", err)
}
return tok
}
// TestTeamTenantResolvesSignedOrg: a well-formed, unexpired, correctly-signed team
// session token resolves to the org in its SIGNED extra.org claim.
func TestTeamTenantResolvesSignedOrg(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
app := mountApp(t)
tok := teamToken(t, "acme", "a-real-team-secret", nil, time.Now().Add(time.Hour).Unix())
// The batch must be something ONLY full capability can store. error+navigation is
// not: both kinds are in publicKinds, so the anonymous lane 503s identically and
// deleting the teamTenant clause entirely would have gone unnoticed.
//
// A customEvent is the discriminator. canonicalType is "event", which is NOT in
// publicKinds, so the projection drops it and the write core is never reached
// (200, accepted:0) — while a resolved member writes it and hits the absent
// warehouse (503).
const custom = `[{"event":"customEvent","properties":{"event":"checkout_started","revenue":42},"timestamp":1750000000000,"distinct_id":"u"}]`
code, res := postBody(t, app, "/v1/event/collect", custom, tok)
if code != http.StatusServiceUnavailable {
t.Fatalf("member POST = %d %+v, want 503 (full capability reached the write core)", code, res)
}
// Same bytes, NO credential: dropped by the projection, never reaching the store.
code, res = postBody(t, app, "/v1/event/collect", custom, "")
if code != http.StatusOK || res.Accepted != 0 || res.Dropped != 1 {
t.Fatalf("anonymous POST = %d %+v, want 200 accepted=0 dropped=1", code, res)
}
// And the resolution itself names the signed org at full capability.
org, ok := resolvedTeamOrg(t, app, tok)
if !ok || org != "acme" {
t.Fatalf("teamTenant = (%q, %v), want (acme, true)", org, ok)
}
}
// TestTeamTenantRefusals is the fail-closed table. Every row must NOT resolve to a
// tenant. A resolution here is a cross-tenant write into an org the caller cannot
// prove it owns.
func TestTeamTenantRefusals(t *testing.T) {
const secret = "a-real-team-secret"
hour := time.Now().Add(time.Hour).Unix()
cases := []struct {
name string
env string
bearer func(t *testing.T) string
}{
{"forged: signed with another key", secret, func(t *testing.T) string {
return teamToken(t, "victim", "attacker-secret", nil, hour)
}},
{"expired", secret, func(t *testing.T) string {
return teamToken(t, "acme", secret, nil, time.Now().Add(-time.Hour).Unix())
}},
{"no org claim", secret, func(t *testing.T) string {
tok, err := token.Generate(teamAccount, "", nil, hour, secret)
if err != nil {
t.Fatalf("token.Generate: %v", err)
}
return tok
}},
// The upstream public default. If this resolved, anyone could mint a token
// for any org, because the key is published.
{"public default secret", "secret", func(t *testing.T) string {
return teamToken(t, "victim", "secret", nil, hour)
}},
// No secret configured on the SERVER. The token itself is perfectly valid —
// this is the posture check: with nothing to verify against, a genuine token
// is not trusted either. Never "no secret ⇒ skip verification".
{"secret unset", "", func(t *testing.T) string {
return teamToken(t, "acme", secret, nil, hour)
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Setenv("SERVER_SECRET", c.env)
app := mountApp(t)
bearer := c.bearer(t)
// A team token that does not resolve is REFUSED, not downgraded. This
// assertion used to accept "503 or 200", which did not merely miss the
// weakness — it ENFORCED it: the fix (naming the team bearer in
// presented()) turns these into 403 and would have failed the old test.
//
// 200-with-rows-under-$public is the exact pathology this door exists to
// prevent: the caller sees success and the org cannot read its own data.
code, _ := postBody(t, app, "/v1/event/collect", teamWire, bearer)
if code != http.StatusForbidden {
t.Fatalf("POST = %d, want 403 (a presented team credential that does not resolve is refused)", code)
}
org, ok := resolvedTeamOrg(t, app, bearer)
if ok {
t.Fatalf("credential resolved to org %q; it must not resolve", org)
}
})
}
}
// resolvedTeamOrg runs teamTenant against a real request context carrying the bearer,
// and returns the org it resolved to (empty when it refused). It exercises the SAME
// function eventTenant calls, so the refusal table above is a statement about
// production admission, not about a copy of it.
func resolvedTeamOrg(t *testing.T, app *zip.App, bearer string) (string, bool) {
t.Helper()
var got string
var resolved bool
probe := zip.New(zip.Config{})
probe.Post("/probe", func(c *zip.Ctx) error {
a, ok := teamTenant(c)
// Record ok INDEPENDENTLY of the org. The previous version assigned only when
// ok, so a mutant returning ("", true) — which normalizeEvent would store as
// an empty tenant column and fanOut would forward under an empty org — was
// indistinguishable from a clean refusal.
got, resolved = a.org, ok
return c.JSON(http.StatusOK, map[string]string{})
})
req := httptest.NewRequest(http.MethodPost, "/probe", strings.NewReader("[]"))
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := probe.Fiber().Test(req)
if err != nil {
t.Fatalf("probe: %v", err)
}
defer func() { _ = resp.Body.Close() }()
return got, resolved
}
// ── the door ─────────────────────────────────────────────────────────────────
// TestTeamDoorIsRegistered proves the route exists and is the TEAM wire, by the
// clearest discriminator available without a warehouse:
//
// /v1/event + the team wire -> everything dropped -> the write core is never
// reached (len(evs)==0 short-circuits) -> 200.
// /v1/event/collect+ the team wire -> events survive admission -> the write core IS
// reached -> 503 (no warehouse in the harness).
//
// A wrong or missing route would 404/405; a route bound to the canonical decoder would
// 200 with dropped=2. Only the correct binding produces 503.
func TestTeamDoorIsRegistered(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
app := mountApp(t)
code, res := postBody(t, app, "/v1/event", teamWire, "")
if code != http.StatusOK || res.Accepted != 0 || res.Dropped != 2 {
t.Fatalf("canonical door with team wire = %d %+v, want 200 accepted=0 dropped=2", code, res)
}
if code, _ := postBody(t, app, "/v1/event/collect", teamWire, ""); code != http.StatusServiceUnavailable {
t.Fatalf("team door with team wire = %d, want 503 (reached the write core)", code)
}
}
// ── capability: the guest lane, and the trust order ──────────────────────────
// TestGuestWritesProjectedIntoItsOwnOrg is the F1 fix, stated positively. A guest is
// neither trusted with the org's whole custom/billing surface nor exiled to $public:
// it writes into its OWN org, through the projection.
//
// The attack this closes: accept a guest invite, lift presentation.metadata.Token out
// of the tab, POST an `order_completed` with a revenue figure, and it landed as an
// unprojected row in the host org — plus a fanOut to that org's GA4/Meta CAPI.
func TestGuestWritesProjectedIntoItsOwnOrg(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
app := mountApp(t)
hour := time.Now().Add(time.Hour).Unix()
guest := teamToken(t, "acme", "a-real-team-secret", map[string]any{"role": token.RoleGuest}, hour)
member := teamToken(t, "acme", "a-real-team-secret", nil, hour)
// The forgeable payload: a custom event carrying revenue. kind "event" is not in
// publicKinds, so the projection drops it whole.
const revenue = `[{"event":"customEvent","properties":{"event":"order_completed","revenue":99999},"timestamp":1750000000000,"distinct_id":"u"}]`
code, res := postBody(t, app, "/v1/event/collect", revenue, guest)
if code != http.StatusOK || res.Accepted != 0 || res.Dropped != 1 {
t.Fatalf("guest revenue POST = %d %+v, want 200 accepted=0 dropped=1 (projected away)", code, res)
}
// A member CAN write it — so the refusal is about the role, not the payload.
if code, _ := postBody(t, app, "/v1/event/collect", revenue, member); code != http.StatusServiceUnavailable {
t.Fatalf("member revenue POST = %d, want 503 (reached the write core)", code)
}
// But the guest is NOT silenced: its errors/pageviews still land, and they land in
// ITS OWN org — not $public, which acme could never read.
code, res = postBody(t, app, "/v1/event/collect", teamWire, guest)
if code != http.StatusServiceUnavailable {
t.Fatalf("guest error/pageview POST = %d %+v, want 503 (admitted, reached the store)", code, res)
}
a, ok := teamAdmission(t, guest)
if !ok || a.org != "acme" || a.full {
t.Fatalf("guest admission = %+v ok=%v, want org=acme full=false", a, ok)
}
a, ok = teamAdmission(t, member)
if !ok || a.org != "acme" || !a.full {
t.Fatalf("member admission = %+v ok=%v, want org=acme full=true", a, ok)
}
}
// TestUnprovenRoleIsNotPrivileged: fail-closed on the claim's absence. A token that
// never proved a workspace role gets the projection, not the benefit of the doubt.
func TestUnprovenRoleIsNotPrivileged(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
hour := time.Now().Add(time.Hour).Unix()
for _, role := range []string{"", "observer", "GUEST", "Member", "owner ,admin"} {
tok := teamToken(t, "acme", "a-real-team-secret", map[string]any{"role": role}, hour)
a, ok := teamAdmission(t, tok)
if !ok {
t.Fatalf("role %q did not resolve at all; it should resolve at reduced capability", role)
}
if a.full {
t.Errorf("role %q was treated as PRIVILEGED; only owner/admin/member are", role)
}
}
// The three that ARE privileged, so the allowlist is not vacuously empty.
for _, role := range []string{token.RoleOwner, token.RoleAdmin, token.RoleMember} {
tok := teamToken(t, "acme", "a-real-team-secret", map[string]any{"role": role}, hour)
if a, ok := teamAdmission(t, tok); !ok || !a.full {
t.Errorf("role %q = %+v ok=%v, want full", role, a, ok)
}
}
// Surrounding whitespace IS trimmed, deliberately: reading a claim has ONE spelling
// across this package and clients/meet, which is the fix for the two guards that
// used to disagree about it. That is safe here because the role is not
// caller-supplied — selectWorkspace signs it from a validInviteRole-checked DB
// column, so " member " has no production path. Case is NOT folded ("Member" above
// is unprivileged), so the allowlist stays exact where it can be.
tok := teamToken(t, "acme", "a-real-team-secret", map[string]any{"role": " member "}, hour)
if a, ok := teamAdmission(t, tok); !ok || !a.full {
t.Errorf("a whitespace-padded role = %+v ok=%v; claim reads are trimmed by design", a, ok)
}
}
// TestInertClaimsGrantAndReduceNothing pins the dead claims as dead. extra.guest and
// extra.readonly were the old guards' inputs and NOTHING mints them; asserting they are
// inert stops someone "restoring" the guards and believing they protect anything.
func TestInertClaimsGrantAndReduceNothing(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
hour := time.Now().Add(time.Hour).Unix()
// On a member they do not REDUCE.
tok := teamToken(t, "acme", "a-real-team-secret",
map[string]any{"guest": "true", "readonly": "true"}, hour)
if a, ok := teamAdmission(t, tok); !ok || !a.full {
t.Errorf("inert claims reduced a member: %+v ok=%v", a, ok)
}
// On a guest they do not ELEVATE.
tok = teamToken(t, "acme", "a-real-team-secret",
map[string]any{"role": token.RoleGuest, "guest": "false", "readonly": "false"}, hour)
if a, ok := teamAdmission(t, tok); !ok || a.full {
t.Errorf("inert claims elevated a guest: %+v ok=%v", a, ok)
}
}
// TestTrustOrderPrefersTheApiCredential makes the documented ordering OBSERVABLE.
// eventTenant's comment says the team token is last so a request holding both is
// attributed to the deliberate API credential — but nothing tested it, so swapping the
// order was a free mutation.
func TestTrustOrderPrefersTheApiCredential(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
// Stand in for IAM's key seam: this key belongs to org "keyorg".
prev := resolveKeyOrg
resolveKeyOrg = func(_ context.Context, key string) (string, bool) {
if key == "hk-the-key" {
return "keyorg", true
}
return "", false
}
defer func() { resolveKeyOrg = prev }()
tok := teamToken(t, "teamorg", "a-real-team-secret", nil, time.Now().Add(time.Hour).Unix())
got, ok := tenantWith(t, map[string]string{
"Authorization": "Bearer " + tok, // team token -> teamorg
"x-api-key": "hk-the-key", // API key -> keyorg
})
if !ok {
t.Fatal("nothing resolved with both credentials present")
}
if got.org != "keyorg" {
t.Fatalf("resolved org = %q, want keyorg — the API credential must win over a tab's session", got.org)
}
// With ONLY the team token, it does resolve — so the assertion above is about
// precedence, not about the team token being ignored.
if got, ok := tenantWith(t, map[string]string{"Authorization": "Bearer " + tok}); !ok || got.org != "teamorg" {
t.Fatalf("team-only resolved (%+v, %v), want teamorg", got, ok)
}
}
// teamAdmission runs teamTenant against a real request context carrying the bearer.
func teamAdmission(t *testing.T, bearer string) (admission, bool) {
t.Helper()
return runTenant(t, map[string]string{"Authorization": "Bearer " + bearer}, func(c *zip.Ctx) (admission, bool) {
return teamTenant(c)
})
}
// tenantWith runs the FULL eventTenant trust order over a set of headers.
func tenantWith(t *testing.T, headers map[string]string) (admission, bool) {
t.Helper()
return runTenant(t, headers, eventTenant)
}
func runTenant(t *testing.T, headers map[string]string, fn func(*zip.Ctx) (admission, bool)) (admission, bool) {
t.Helper()
var got admission
var ok bool
probe := zip.New(zip.Config{})
probe.Post("/probe", func(c *zip.Ctx) error {
got, ok = fn(c)
return c.JSON(http.StatusOK, map[string]string{})
})
req := httptest.NewRequest(http.MethodPost, "/probe", strings.NewReader("[]"))
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := probe.Fiber().Test(req)
if err != nil {
t.Fatalf("probe: %v", err)
}
defer func() { _ = resp.Body.Close() }()
return got, ok
}
// TestUnidentifiableBearerStillTakesTheAnonymousLane is the OTHER half of the F2 fix,
// and the reason presented() names the team bearer STRUCTURALLY rather than treating
// every Bearer as presented. A stale or foreign JWT — no `account` claim — must keep
// degrading to the anonymous projection, exactly as before this file learned about
// team tokens. Turning those into 403 would be a refusal on evidence we do not have.
func TestUnidentifiableBearerStillTakesTheAnonymousLane(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
app := mountApp(t)
// A well-formed JWT with no `account` claim (an IAM-shaped bearer).
foreign := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiJ1c2VyLTEiLCJpc3MiOiJodHRwczovL2hhbnpvLmlkIn0.c2ln"
if code, _ := postBody(t, app, "/v1/event/collect", teamWire, foreign); code != http.StatusServiceUnavailable {
t.Fatalf("foreign bearer = %d, want 503 (anonymous lane reached the store), NOT 403", code)
}
if teamPresented2(t, foreign) {
t.Error("a bearer with no account claim was counted as a presented team credential")
}
// A team-shaped bearer IS counted, which is what makes the 403 path fire.
tok := teamToken(t, "acme", "another-secret", nil, time.Now().Add(time.Hour).Unix())
if !teamPresented2(t, tok) {
t.Error("a team-shaped bearer was not counted as presented")
}
}
func teamPresented2(t *testing.T, bearer string) bool {
t.Helper()
var got bool
probe := zip.New(zip.Config{})
probe.Post("/probe", func(c *zip.Ctx) error {
got = teamPresented(c)
return c.JSON(http.StatusOK, map[string]string{})
})
req := httptest.NewRequest(http.MethodPost, "/probe", strings.NewReader("[]"))
req.Header.Set("Authorization", "Bearer "+bearer)
resp, _ := probe.Fiber().Test(req)
defer func() { _ = resp.Body.Close() }()
return got
}
// ── the reduced lane, observed at the write core ─────────────────────────────
// TestGuestRowsLandInItsOwnOrgNotPublic is the assertion the previous version of this
// suite only CLAIMED to make. TestGuestWritesProjectedIntoItsOwnOrg checks the org on
// teamAdmission — a pure function — and then uses a 503 as its end-to-end proof. But the
// 503 comes from the absent warehouse either way, so swapping handle's `a.org` for
// publicTenant survived: the whole rationale of this lane is "not $public", and nothing
// tested it.
//
// With the fake warehouse the tenant column is directly observable, so this binds to
// where the row actually lands.
func TestGuestRowsLandInItsOwnOrgNotPublic(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
roomyRate(t)
w := fakeWarehouse(t)
app := mountApp(t)
guest := teamToken(t, "acme", "a-real-team-secret",
map[string]any{"role": token.RoleGuest}, time.Now().Add(time.Hour).Unix())
// One event per request: the fake warehouse records one entry per INSERT, and a
// batch becomes a single multi-row INSERT, so tenants() reports per statement.
// Both kinds are exercised, one request each.
for _, body := range []string{
`[{"event":"error","properties":{"error_message":"boom"},"timestamp":1750000000000,"distinct_id":"u"}]`,
`[{"event":"navigation","properties":{"path":"/pricing"},"timestamp":1750000000000,"distinct_id":"u"}]`,
} {
code, res := postBody(t, app, "/v1/event/collect", body, guest)
if code != http.StatusOK || res.Accepted != 1 {
t.Fatalf("guest POST = %d %+v, want 200 accepted:1 (admitted and written)", code, res)
}
}
got := w.tenants(t)
if len(got) != 2 {
t.Fatalf("wrote %d statements, want 2", len(got))
}
for _, g := range got {
if g == publicTenant {
t.Errorf("a guest's row was filed under %q, where its org cannot read it", publicTenant)
}
if g != "acme" {
t.Errorf("tenant = %q, want acme (the SIGNED org)", g)
}
}
}
// TestReducedLaneAttributesToTheSignedAccount is N-5. The projection strips revenue,
// personId, groupId and the event name — but distinct_id survives, because it is the
// join key that makes the lane useful. The team SPA puts an ACCOUNT IDENTIFIER there, so
// on a real org a guest could attribute pageviews and errors to a named colleague. The
// fix is not to strip it but to stop taking it from the caller.
func TestReducedLaneAttributesToTheSignedAccount(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
roomyRate(t)
w := fakeWarehouse(t)
app := mountApp(t)
guest := teamToken(t, "acme", "a-real-team-secret",
map[string]any{"role": token.RoleGuest}, time.Now().Add(time.Hour).Unix())
// The forgery: claim a colleague as the person, and a colleague's anonymous alias.
const victim = "ada@acme.example"
body := `[{"event":"navigation","properties":{"path":"/salaries","$anonymous_id":"anon-of-ada"},` +
`"timestamp":1750000000000,"distinct_id":"` + victim + `"}]`
if code, res := postBody(t, app, "/v1/event/collect", body, guest); code != http.StatusOK || res.Accepted != 1 {
t.Fatalf("guest POST = %d %+v, want 200 accepted:1", code, res)
}
if got := w.col(t, 0, "distinct_id"); got == victim {
t.Fatalf("the guest attributed its pageview to %q — person-level forgery inside a real tenant", victim)
}
if got := w.col(t, 0, "distinct_id"); got != teamAccount {
t.Errorf("distinct_id = %v, want the SIGNED account %q", got, teamAccount)
}
// The pre-login alias is cleared: it exists to stitch an anonymous session to a
// person later, and there is nothing to stitch when the person is already known.
if got := w.col(t, 0, "anonymous_id"); got != "" {
t.Errorf("anonymous_id = %v, want empty on the attributed lane", got)
}
// The FULL lane is unchanged: a member is trusted to attribute its own writes, so
// the distinct_id it sends is the one stored. Without this, the test above would
// pass for a version that clobbered identity everywhere.
member := teamToken(t, "acme", "a-real-team-secret", nil, time.Now().Add(time.Hour).Unix())
w2 := fakeWarehouse(t)
if code, res := postBody(t, app, "/v1/event/collect", body, member); code != http.StatusOK || res.Accepted != 1 {
t.Fatalf("member POST = %d %+v, want 200 accepted:1", code, res)
}
if got := w2.col(t, 0, "distinct_id"); got != victim {
t.Errorf("member distinct_id = %v, want %q — the full lane must not be rewritten", got, victim)
}
}
// TestAnonymousLaneIdentityIsUntouched: the two genuinely anonymous callers have no
// signed identity to substitute, so attribute() must not reach them. A credential-less
// beacon keeps the distinct_id it sent (into $public, where it means nothing).
func TestAnonymousLaneIdentityIsUntouched(t *testing.T) {
t.Setenv("SERVER_SECRET", "a-real-team-secret")
roomyRate(t)
w := fakeWarehouse(t)
app := mountApp(t)
body := `[{"event":"navigation","properties":{"path":"/pricing"},"timestamp":1750000000000,"distinct_id":"visitor-7"}]`
if code, res := postBody(t, app, "/v1/event/collect", body, ""); code != http.StatusOK || res.Accepted != 1 {
t.Fatalf("anonymous POST = %d %+v, want 200 accepted:1", code, res)
}
if got := w.tenants(t); len(got) != 1 || got[0] != publicTenant {
t.Fatalf("anonymous tenant = %v, want [%s]", got, publicTenant)
}
if got := w.col(t, 0, "distinct_id"); got != "visitor-7" {
t.Errorf("anonymous distinct_id = %v, want visitor-7 (nobody signed for it, so there is nothing to substitute)", got)
}
}
+432
View File
@@ -0,0 +1,432 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package meet is the CONTROL plane for the virtual office: it decides who may
// join which room, and says so by minting a short-lived LiveKit access token.
//
// POST /v1/meet/getToken {roomName, _id, participantName} -> the token, as text
//
// The MEDIA plane is not here and never will be. Audio, video and screen share ride
// a direct browser↔LiveKit WebRTC connection (LIVEKIT_WS = wss://live.hanzo.bot);
// media is not a thing to proxy through an API binary. What moved into this binary
// is the ONE decision a server has to make about a call — may this caller join this
// room — which needs the team session secret and the LiveKit signing key, and needs
// no pod of its own to hold them.
//
// TWO KEYS, TWO ROLES, and they never mix:
//
// - SERVER_SECRET verifies the CALLER. It is the HS256 key clients/team signs
// session tokens with, so "is this a real member of this workspace" is answered
// against the same signature the rest of /v1/team trusts. It arrives as env from
// the KMS-synced `team-secrets`.
// - The LiveKit api key/secret signs the ANSWER, and it is read from the SAME
// keys.yaml file the LiveKit server itself validates against (Secret
// `livekit-keys`, mounted read-only). ONE representation of that material, so it
// cannot drift: a second copy projected into env would mint tokens that look
// perfect and are refused at the media edge, which is the silent failure this
// whole package is trying not to have.
//
// Missing or ambiguous material is a 503, LOUDLY: the reason names the file and the
// Secret in the log at boot, while the caller gets an unadorned "not configured" (an
// unauthenticated 503 is not the place to enumerate our secret plumbing).
package meet
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/team/token"
"github.com/zap-proto/zip"
"gopkg.in/yaml.v3"
)
// ttl is how long a minted join token is good for. Ten minutes: long enough to
// complete a join handshake on a bad connection, short enough that a leaked token
// is worthless before anyone can use it. The client re-mints per join, so this is
// not a session length — a call that has already started is a LiveKit connection
// and does not re-check the token.
const ttl = 10 * time.Minute
// keyFileEnv overrides where keys.yaml is read from. It exists because the LiveKit
// server takes the same knob (--key-file), so the path is a deployment fact on both
// sides of the pair rather than a constant on one — and because a test must be able
// to point at a temp file.
const keyFileEnv = "LIVEKIT_KEY_FILE"
// keyFile is where the manifest mounts Secret `livekit-keys`. Same file, same Secret,
// same content the LiveKit server reads.
const keyFile = "/etc/livekit-keys/keys.yaml"
// apiKeyEnv names WHICH api key in keys.yaml to sign with, for the case where the file
// declares more than one. Unset is correct and normal for a single-key file.
const apiKeyEnv = "LIVEKIT_API_KEY"
// state is meet's own data: the caller-verifying key, the answer-signing pair, and
// the reason it is unusable when it is. reason is the ONE flag — a non-empty reason
// IS "not configured", so there is no way for the two to disagree.
type state struct {
teamSecret string // SERVER_SECRET — verifies the caller's team session
apiKey string // LiveKit api key — the `iss` LiveKit matches on
apiSecret string // LiveKit api secret — signs the minted token
reason string // why this is unusable; empty means usable
}
// ready reports whether meet can mint. Fail-closed: an unconfigured deploy refuses
// every mint rather than issuing a token nobody can verify.
func (s state) ready() bool { return s.reason == "" }
// load assembles the signing material and, when it cannot, says exactly why. Every
// failure path produces a reason naming the file or env var an operator has to fix —
// this used to return a bare zero value, which made a misconfigured deploy an
// indistinguishable permanent 503 with nothing in the log to chase.
func load() state {
secret := os.Getenv("SERVER_SECRET")
if secret == "" || secret == "secret" {
// The upstream public default is treated as absent for the reason
// clients/team's resolveSecret does: a known key lets anyone mint a session
// naming any workspace — here, a join token for a room they were never in.
return state{reason: "SERVER_SECRET is unset or the public default literal (K8s Secret team-secrets, key SERVER_SECRET)"}
}
path := os.Getenv(keyFileEnv)
if path == "" {
path = keyFile
}
key, apiSecret, err := readKeys(path)
if err != nil {
return state{reason: err.Error()}
}
return state{teamSecret: secret, apiKey: key, apiSecret: apiSecret}
}
// readKeys parses a LiveKit key file: a YAML map of apiKey -> apiSecret, which is the
// format the LiveKit server's --key-file takes. It returns the single pair, or an
// error naming the file and Secret.
//
// EXACTLY ONE entry is required. Zero is unconfigured. More than one is AMBIGUOUS,
// and ambiguity here is refused rather than resolved: Go map iteration is random, so
// "just take the first" would pick a different key per process start, and a token
// signed under a key the caller's room was not provisioned for fails at the media
// edge intermittently — the worst possible failure shape. If a deployment ever needs
// several api keys, the code that chooses between them has to be written on purpose.
func readKeys(path string) (string, string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("cannot read the LiveKit key file %s (K8s Secret livekit-keys, key keys.yaml): %v", path, err)
}
// gopkg.in/yaml.v3 into map[string]string — the EXACT library and target type the
// LiveKit server decodes this file with (livekit/pkg/config: `Keys
// map[string]string`, yaml.v3), so we and the verifier read identical bytes to
// identical strings by construction.
//
// This was sigs.k8s.io/yaml, which is NOT equivalent: it routes YAML through JSON
// and coerces a scalar to the target type, so measured on real input it turned
// 0123456789 -> "1.2345679e+08" yes -> "true" 0x1f -> "31"
// 1e5 -> "100000" no -> "false" 00 -> "0"
// and silently took the LAST of a duplicated key. Any of those mints a token that
// verifies nowhere while the log says "mounted" — the exact silent failure this
// file claims to prevent. yaml.v3 preserves all of them verbatim and REFUSES a
// duplicate key outright, which we get for free by using the right library.
var keys map[string]string
if err := yaml.Unmarshal(raw, &keys); err != nil {
return "", "", fmt.Errorf("cannot parse the LiveKit key file %s (K8s Secret livekit-keys, key keys.yaml) as a YAML apiKey->apiSecret map: %v", path, err)
}
names := make([]string, 0, len(keys))
for k := range keys {
names = append(names, k)
}
sort.Strings(names) // stable messages; no decision depends on map order
if len(names) == 0 {
return "", "", fmt.Errorf("the LiveKit key file %s (K8s Secret livekit-keys, key keys.yaml) declares no api key", path)
}
// A LiveKit key file is a MAP because a server may hold several keys. When it
// does, LIVEKIT_API_KEY names which one this binary signs with. Selecting by name
// is the only safe way to resolve the ambiguity: Go map order is random, so
// "take the first" would pick differently per process start and produce tokens
// that fail at the media edge intermittently.
key := ""
if want := strings.TrimSpace(os.Getenv(apiKeyEnv)); want != "" {
if _, found := keys[want]; !found {
return "", "", fmt.Errorf("%s names api key %q, which the LiveKit key file %s (K8s Secret livekit-keys, key keys.yaml) does not declare (it has: %s)", apiKeyEnv, want, path, strings.Join(names, ", "))
}
key = want
} else if len(names) > 1 {
return "", "", fmt.Errorf("the LiveKit key file %s (K8s Secret livekit-keys, key keys.yaml) declares %d api keys (%s); set %s to name which one to sign with — refusing to pick", path, len(names), strings.Join(names, ", "), apiKeyEnv)
} else {
key = names[0]
}
apiSecret := keys[key]
// Blank-ish is refused, but the values are returned BYTE-EXACT — deliberately not
// trimmed. The only property that matters is that the pair we sign with is
// identical to the pair the LiveKit server read from these same bytes. Trimming
// would silently diverge from any reader that does not trim: the api key is the
// `iss` LiveKit matches on, and the secret IS the signing key, so one stripped
// space produces tokens that mint perfectly and verify nowhere. Consistency with
// the other reader beats tidiness.
if strings.TrimSpace(key) == "" || strings.TrimSpace(apiSecret) == "" {
return "", "", fmt.Errorf("the LiveKit key file %s (K8s Secret livekit-keys, key keys.yaml) has an empty api key or secret", path)
}
return key, apiSecret, nil
}
// Mount wires /v1/meet/* onto app. The route is registered even when unconfigured so
// the surface always answers under its OWN name with an honest 503, rather than
// falling through to some other subsystem's catch-all and reporting a 404 for a
// service that exists but has no keys.
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("meet.Mount: nil app")
}
if deps.Logger == nil {
return fmt.Errorf("meet.Mount: nil deps.Logger")
}
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "meet"), State: load()}
// The path suffix is the CALLER's, not ours. The office client POSTs
// concatLink(LOVE_ENDPOINT, '/getToken') from a published bundle, so with
// LOVE_ENDPOINT=/v1/meet the wire lands here. Renaming it means shipping a new
// front image, not editing a manifest.
app.Post("/v1/meet/getToken", cloud.Handle(s, mint))
// /v1/meet/health makes "the office is unconfigured" a SIGNAL rather than a grep.
// A boot log line is invisible to a dashboard and rotates away; this is the same
// contract every other subsystem exposes, so the existing probe/alerting surface
// picks it up with no new machinery. It carries the reason because /v1/*/health is
// an operator surface, not the unauthenticated mint path.
app.Get("/v1/meet/health", cloud.Handle(s, health))
if !s.State.ready() {
// ERROR, not warn, and it names the file/Secret to fix. A subsystem that can
// never serve a single request is not a warning — and the previous version of
// this line said only "not all set", which is exactly why a Secret that was
// empty in the cluster could have shipped as a permanent, silent 503.
s.Log.Error("meet subsystem UNCONFIGURED — POST /v1/meet/getToken will 503 on every call until this is fixed; the office (video/audio rooms) is down",
"reason", s.State.reason, "prefix", "/v1/meet")
return nil
}
// apiKey is an identifier, not a secret (it is the public `iss` of every minted
// token), so logging it is what lets an operator confirm the binary and the
// LiveKit server agree on which key pair is in play. The secret is never logged.
s.Log.Info("meet subsystem mounted", "prefix", "/v1/meet", "ttl", ttl.String(), "livekitApiKey", s.State.apiKey)
return nil
}
// health reports whether meet can mint, and when it cannot, why. 503 + ready:false so
// the degraded state is legible to a probe and to a dashboard, not just to whoever
// greps the boot log.
func health(s *cloud.Service[state], c *zip.Ctx) error {
res := map[string]any{"service": "meet", "status": "ok"}
if !s.State.ready() {
// ready:false IS the dashboard fact, and it is all a probe needs. The REASON —
// which names the key-file path and the Secret — stays in the boot log, because
// this endpoint takes no credential and is reachable on five public hosts. The
// api key is withheld for the same reason; it is not secret, but an unauthed
// caller has no business enumerating which key pair this binary signs with.
// (Leaking it here while deliberately keeping it out of the getToken 503 would
// have been two postures in one file.)
res["status"], res["ready"] = "degraded", false
return c.JSON(http.StatusServiceUnavailable, res)
}
res["ready"] = true
return c.JSON(http.StatusOK, res)
}
// request is the office client's wire. `_id` is the SPA's person ref — accepted because
// the published bundle sends it, and IGNORED because the participant identity now comes
// from the signed token (see mint). participantName is a display name only.
type request struct {
RoomName string `json:"roomName"`
ID string `json:"_id"`
ParticipantName string `json:"participantName"`
}
// mint answers POST /v1/meet/getToken: verify the caller belongs to the room's
// workspace, then hand back a join token for exactly that room.
//
// The response is the RAW token as text/plain, not JSON. That is the caller's
// contract — the office client reads it with res.text() — and it is also the honest
// shape: the body is one opaque string, so wrapping it in an object would add a
// envelope neither side needs.
func mint(s *cloud.Service[state], c *zip.Ctx) error {
st := s.State
if !st.ready() {
// The CALLER gets the fact, not the plumbing: this 503 is reachable without
// any credential, so it must not enumerate our file paths and Secret names.
// The full reason went to the log at boot (Mount), which is where an operator
// is looking.
return zip.Errorf(http.StatusServiceUnavailable, "meet: the office is not configured")
}
var req request
if err := json.Unmarshal(c.Body(), &req); err != nil {
return zip.ErrBadRequest("malformed request body")
}
room := strings.TrimSpace(req.RoomName)
if room == "" {
return zip.ErrBadRequest("roomName required")
}
t, ok := st.admits(room, c.Header("Authorization"))
if !ok {
return zip.Errorf(http.StatusUnauthorized, "not a member of this room's workspace")
}
// 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
// minting with a caller-supplied `_id` let any member of a workspace kick a
// colleague out of a call by claiming their identity, and impersonate them to
// everyone else in the room. Upstream did this too; it is still wrong. The signed
// account is the one identity the caller cannot choose.
//
// The body's `_id` (the SPA's person ref) is deliberately ignored rather than
// checked: verifying it belongs to the caller would need the person<->account
// mapping from clients/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)
if identity == "" {
return zip.Errorf(http.StatusUnauthorized, "token carries no account")
}
tok, err := st.grant(room, identity, strings.TrimSpace(req.ParticipantName), time.Now())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "meet: mint failed")
}
return c.String(http.StatusOK, tok)
}
// workspace is the workspace a room belongs to. Room names are minted client-side as
// "<workspaceUuid>_<roomName>_<roomId>", so the workspace is the leading segment.
// This is the ONLY thing binding a room to a tenant, which is why admits compares it
// against the SIGNED workspace claim and not against anything in the body.
func workspace(room string) string {
ws, _, _ := strings.Cut(room, "_")
return ws
}
// admits decides whether the bearer may join room. Every clause is a refusal; there
// is no branch that admits by default.
//
// - the token must VERIFY against SERVER_SECRET (signature, exp, nbf) — so a forged
// or stale session is not a member;
// - its SIGNED workspace claim must equal the room's workspace prefix — this is the
// tenant boundary. Without it, any member of any workspace could mint a join token
// for any room in any other workspace by naming it;
// - an empty workspace claim is refused outright, so a session token that is not
// bound to a workspace cannot match a room that has no separator in its name;
// - the token must carry a PRIVILEGED workspace role (token.Privileged, the one
// predicate that reads the signed extra.role). A guest is a reduced principal and
// a seat in a colleague's meeting is not a reduced-session privilege. This used to
// compare extra.readonly/extra.guest — claims NOTHING in this repo mints, so the
// 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)
if raw == "" {
return nil, false
}
t, err := token.Decode(raw, s.teamSecret, true)
if err != nil {
return nil, false
}
if t.Workspace == "" || t.Workspace != workspace(room) {
return nil, false
}
if !t.Privileged() {
return nil, false
}
return t, true
}
// bearer extracts the token from an "Authorization: Bearer <t>" header (scheme
// case-insensitive). Empty when absent or not a bearer.
func bearer(h string) string {
h = strings.TrimSpace(h)
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
return strings.TrimSpace(h[7:])
}
return ""
}
// video is LiveKit's VideoGrant, narrowed to the two fields a join token needs. The
// grant is deliberately minimal: roomJoin into ONE named room. Every capability
// LiveKit defaults on for a joiner (publish, subscribe) follows from that; every
// capability it does not (roomAdmin, roomCreate, roomList, recorder, ingressAdmin)
// stays off because it is not named here. A token that cannot express a privilege
// cannot leak it.
type video struct {
RoomJoin bool `json:"roomJoin"`
Room string `json:"room"`
}
// claims is the LiveKit access-token payload: RFC 7519 registered claims plus
// LiveKit's grant object. The shape is LiveKit's, not ours — it must match what the
// media server verifies, so the field set here mirrors livekit/protocol's
// auth.tokenClaims (iss=apiKey, sub=identity, iat/nbf/exp, name, video).
type claims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Nbf int64 `json:"nbf"`
Exp int64 `json:"exp"`
Name string `json:"name,omitempty"`
Video video `json:"video"`
}
// header is the fixed JOSE header for every token this package mints. HS256 is not a
// choice here — it is what LiveKit verifies a shared-secret token with. It is a
// constant rather than a field precisely so no request can influence the algorithm:
// there is no code path that could be talked into `alg: none`.
var header = base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
// grant mints the join token: a compact HS256 JWT over the claims above.
//
// This composes stdlib crypto/hmac + crypto/sha256 rather than taking a JWT library,
// for two reasons. First, clients/team/token already establishes this exact idiom for
// the platform's own HS256 tokens, and a second way to make a JWT in one binary is a
// second way to get it wrong. Second, this side only ever SIGNS: the verifier is the
// LiveKit server, so the whole class of bugs a JWT library earns its keep against —
// alg confusion, `alg: none`, non-constant-time comparison — has no code path here.
// The primitives themselves are stdlib; nothing cryptographic is hand-rolled.
func (s state) grant(room, identity, name string, now time.Time) (string, error) {
// Defense in depth at the CRYPTO boundary, not just at the gate. crypto/hmac
// accepts an empty key and returns a perfectly well-formed MAC, so an empty
// signing key does not fail — it silently produces a token that verifies under
// the empty key and under nothing the LiveKit server holds. Refusing here means
// removing the ready() check upstream still cannot mint an unverifiable token.
if s.apiKey == "" || s.apiSecret == "" {
return "", errors.New("meet: refusing to sign with an empty LiveKit api key or secret")
}
payload, err := json.Marshal(claims{
Iss: s.apiKey,
Sub: identity,
Iat: now.Unix(),
Nbf: now.Unix(),
Exp: now.Add(ttl).Unix(),
Name: name,
Video: video{RoomJoin: true, Room: room},
})
if err != nil {
return "", err
}
signing := header + "." + base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, []byte(s.apiSecret))
mac.Write([]byte(signing))
return signing + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
+802
View File
@@ -0,0 +1,802 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package meet
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/team/token"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
const (
teamSecret = "a-real-team-secret"
apiKey = "APIabcdef123456"
apiSecret = "a-real-livekit-secret"
account = "550e8400-e29b-41d4-a716-446655440000"
workspaceA = "11111111-1111-4111-8111-111111111111"
workspaceB = "22222222-2222-4222-8222-222222222222"
)
// roomIn builds a room name exactly as the office client does:
// "<workspaceUuid>_<roomName>_<roomId>".
func roomIn(ws string) string { return ws + "_standup_room-7" }
// keyFileWith writes a LiveKit key file with the given raw body and returns its path.
func keyFileWith(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "keys.yaml")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("write key file: %v", err)
}
return p
}
// mount stands the subsystem up against a real key FILE, which is how production
// reads it (Secret livekit-keys/keys.yaml, mounted read-only) — not against env
// scalars, which is the shape that turned out not to exist in the cluster.
func mount(t *testing.T, team, key, secret string) *zip.App {
t.Helper()
body := ""
if key != "" || secret != "" {
body = key + ": " + secret + "\n"
}
return mountWithKeyFile(t, team, keyFileWith(t, body))
}
func mountWithKeyFile(t *testing.T, team, path string) *zip.App {
t.Helper()
t.Setenv("SERVER_SECRET", team)
t.Setenv(keyFileEnv, path)
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
}
// session mints a workspace token the way selectWorkspace does. The role defaults to
// member because that is the ordinary caller; pass extra{"role": "guest"} for a guest
// and extra{"role": ""} to model a token that never proved a role (a pre-workspace
// session token, or one minted before the claim existed).
func session(t *testing.T, ws, secret string, extra map[string]any, exp int64) string {
t.Helper()
e := map[string]any{"role": token.RoleMember}
for k, v := range extra {
e[k] = v
}
tok, err := token.Generate(account, ws, e, exp, secret)
if err != nil {
t.Fatalf("token.Generate: %v", err)
}
return tok
}
func ask(t *testing.T, app *zip.App, room, id, bearer string) (int, string) {
t.Helper()
body, _ := json.Marshal(request{RoomName: room, ID: id, ParticipantName: "Ada"})
req := httptest.NewRequest(http.MethodPost, "/v1/meet/getToken", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("POST /v1/meet/getToken: %v", err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// ── the token LiveKit will see ───────────────────────────────────────────────
// verify is an INDEPENDENT re-implementation of what the LiveKit server does with an
// incoming token: split it, recompute the HMAC over header.payload with the shared
// api secret, and constant-time compare. It deliberately does not call grant's
// helpers — a test that reuses the code under test to check the code under test
// proves only self-consistency. If this passes, LiveKit accepts the signature.
func verify(t *testing.T, tok, secret string) map[string]any {
t.Helper()
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("token has %d segments, want 3", len(parts))
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts[0] + "." + parts[1]))
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(parts[2]), []byte(want)) {
t.Fatal("signature does not verify under the LiveKit api secret")
}
var head map[string]any
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("header not base64url: %v", err)
}
if err := json.Unmarshal(raw, &head); err != nil {
t.Fatalf("header not JSON: %v", err)
}
if head["alg"] != "HS256" {
t.Errorf("alg = %v, want HS256", head["alg"])
}
var payload map[string]any
raw, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("payload not base64url: %v", err)
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("payload not JSON: %v", err)
}
return payload
}
// TestMintProducesVerifiableJoinToken pins EVERY claim LiveKit reads, against the
// shape in livekit/protocol auth.tokenClaims (iss=apiKey, sub=identity, iat/nbf/exp,
// name, video). A drift in any one of them is a token the media server rejects, which
// on a live cluster looks like "the call button does nothing".
func TestMintProducesVerifiableJoinToken(t *testing.T) {
app := mount(t, teamSecret, apiKey, apiSecret)
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
room := roomIn(workspaceA)
before := time.Now()
code, tok := ask(t, app, room, "person-42", bearer)
if code != http.StatusOK {
t.Fatalf("mint = %d %s, want 200", code, tok)
}
claims := verify(t, tok, apiSecret)
if claims["iss"] != apiKey {
t.Errorf("iss = %v, want the api key %q", claims["iss"], apiKey)
}
if claims["sub"] != account {
t.Errorf("sub = %v, want the SIGNED account %q (never the body's _id)", claims["sub"], account)
}
if claims["name"] != "Ada" {
t.Errorf("name = %v, want Ada", claims["name"])
}
grant, ok := claims["video"].(map[string]any)
if !ok {
t.Fatalf("video grant missing: %v", claims)
}
if grant["roomJoin"] != true {
t.Errorf("video.roomJoin = %v, want true", grant["roomJoin"])
}
if grant["room"] != room {
t.Errorf("video.room = %v, want %q", grant["room"], room)
}
// TTL: ten minutes from mint, with nbf already valid.
exp, _ := claims["exp"].(float64)
nbf, _ := claims["nbf"].(float64)
if d := time.Unix(int64(exp), 0).Sub(before); d < 9*time.Minute || d > 11*time.Minute {
t.Errorf("exp is %s out, want ~10m", d)
}
if time.Unix(int64(nbf), 0).After(time.Now()) {
t.Errorf("nbf %v is in the future — the token is not yet valid when issued", nbf)
}
}
// TestGrantCarriesNoAdminPrivilege: the grant must name roomJoin and a room, and
// NOTHING else. LiveKit treats every privilege as opt-in by presence, so this is the
// least-privilege assertion — a token that never mentions roomAdmin cannot confer it,
// and a leaked join token stays a join token.
func TestGrantCarriesNoAdminPrivilege(t *testing.T) {
app := mount(t, teamSecret, apiKey, apiSecret)
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
_, tok := ask(t, app, roomIn(workspaceA), "person-42", bearer)
claims := verify(t, tok, apiSecret)
grant := claims["video"].(map[string]any)
for _, priv := range []string{"roomAdmin", "roomCreate", "roomList", "roomRecord", "recorder", "ingressAdmin", "agent", "hidden"} {
if _, present := grant[priv]; present {
t.Errorf("grant names %q; a join token must not carry it", priv)
}
}
if len(grant) != 2 {
t.Errorf("grant has %d fields (%v), want exactly roomJoin+room", len(grant), grant)
}
// The team session secret must never appear in something handed to a browser.
if strings.Contains(tok, teamSecret) || strings.Contains(tok, apiSecret) {
t.Fatal("a signing key leaked into the minted token")
}
}
// ── admission ────────────────────────────────────────────────────────────────
// TestMintRefusals is the fail-closed table. Every row must be refused; a 200 in any
// of them is an unauthorized person in a meeting.
func TestMintRefusals(t *testing.T) {
hour := time.Now().Add(time.Hour).Unix()
cases := []struct {
name string
room string
bearer func(t *testing.T) string
}{
{"no bearer at all", roomIn(workspaceA), func(t *testing.T) string { return "" }},
{"not a token", roomIn(workspaceA), func(t *testing.T) string { return "not-a-jwt" }},
{"forged: signed with another key", roomIn(workspaceA), func(t *testing.T) string {
return session(t, workspaceA, "attacker-secret", nil, hour)
}},
{"expired session", roomIn(workspaceA), func(t *testing.T) string {
return session(t, workspaceA, teamSecret, nil, time.Now().Add(-time.Hour).Unix())
}},
// THE tenant boundary: a real member of workspace B naming a room in
// workspace A. Room names are client-chosen, so this is the only thing
// stopping cross-workspace eavesdropping.
{"member of another workspace", roomIn(workspaceA), func(t *testing.T) string {
return session(t, workspaceB, teamSecret, nil, hour)
}},
{"session not bound to any workspace", roomIn(workspaceA), func(t *testing.T) string {
return session(t, "", teamSecret, nil, hour)
}},
// A room name with no separator: workspace(room) is the whole string, and an
// unbound session must still not match it.
{"separator-less room, unbound session", "lobby", func(t *testing.T) string {
return session(t, "", teamSecret, nil, hour)
}},
// The REAL reduced principal: the signed workspace role. These rows used to
// set extra.guest/extra.readonly, which NOTHING in this repo mints — so they
// passed against a token production never produces while every actual guest
// was admitted.
{"guest role", roomIn(workspaceA), func(t *testing.T) string {
return session(t, workspaceA, teamSecret, map[string]any{"role": token.RoleGuest}, hour)
}},
// FAIL-CLOSED on an unproven role: a token with no role claim has not shown it
// is a member, so it does not get a seat.
{"no role claim", roomIn(workspaceA), func(t *testing.T) string {
return session(t, workspaceA, teamSecret, map[string]any{"role": ""}, hour)
}},
{"unknown future role", roomIn(workspaceA), func(t *testing.T) string {
return session(t, workspaceA, teamSecret, map[string]any{"role": "observer"}, hour)
}},
// The claims the old guards read are now meaningless — asserting that keeps
// anyone from "restoring" them and believing they do something.
{"inert extra.guest does not reduce a member", roomIn(workspaceA), func(t *testing.T) string {
return session(t, workspaceB, teamSecret, map[string]any{"guest": "true"}, hour)
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
app := mount(t, teamSecret, apiKey, apiSecret)
code, body := ask(t, app, c.room, "person-42", c.bearer(t))
if code != http.StatusUnauthorized {
t.Fatalf("got %d %q, want 401", code, body)
}
})
}
}
// TestMintRejectsPublicDefaultTeamSecret: with the upstream default "secret" as the
// team key, anyone can mint a session naming any workspace — so the subsystem must
// treat that key as absent and refuse everything (503), not verify against it.
func TestMintRejectsPublicDefaultTeamSecret(t *testing.T) {
app := mount(t, "secret", apiKey, apiSecret)
bearer := session(t, workspaceA, "secret", nil, time.Now().Add(time.Hour).Unix())
if code, body := ask(t, app, roomIn(workspaceA), "person-42", bearer); code != http.StatusServiceUnavailable {
t.Fatalf("got %d %q, want 503 — the public default key must never verify a caller", code, body)
}
}
// TestMintFailsClosedUnconfigured: a missing key of any kind is a 503, and the route
// still exists (never a 404) so the failure is attributable to this subsystem.
func TestMintFailsClosedUnconfigured(t *testing.T) {
cases := []struct{ name, team, key, secret string }{
{"no team secret", "", apiKey, apiSecret},
{"empty key file", teamSecret, "", ""},
{"api key with an empty secret", teamSecret, apiKey, ""},
{"nothing configured", "", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
app := mount(t, c.team, c.key, c.secret)
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
code, body := ask(t, app, roomIn(workspaceA), "person-42", bearer)
if code != http.StatusServiceUnavailable {
t.Fatalf("got %d %q, want 503", code, body)
}
})
}
}
// TestMintRequiresRoomAndIdentity: LiveKit refuses a join grant with no identity, so
// an empty _id is a 400 here rather than a token that cannot work. Both checks run
// BEFORE admission, so a malformed request never reaches the verifier.
func TestMintRequiresRoomAndIdentity(t *testing.T) {
app := mount(t, teamSecret, apiKey, apiSecret)
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
if code, _ := ask(t, app, "", "person-42", bearer); code != http.StatusBadRequest {
t.Errorf("empty roomName = %d, want 400", code)
}
// An empty _id is NO LONGER an error: the identity comes from the token, so the
// body's person ref is ignored entirely (see TestIdentityComesFromTheToken).
if code, _ := ask(t, app, roomIn(workspaceA), "", bearer); code != http.StatusOK {
t.Errorf("empty _id = %d, want 200 — the body's _id is not load-bearing", code)
}
req := httptest.NewRequest(http.MethodPost, "/v1/meet/getToken", strings.NewReader("not json"))
req.Header.Set("Authorization", "Bearer "+bearer)
resp, _ := app.Fiber().Test(req)
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("malformed body = %d, want 400", resp.StatusCode)
}
}
// TestWorkspaceOfRoom pins the room→workspace parse against the client's format.
func TestWorkspaceOfRoom(t *testing.T) {
cases := map[string]string{
workspaceA + "_standup_room-7": workspaceA,
workspaceA + "_a_b_c": workspaceA, // extra separators stay in the room part
"lobby": "lobby", // no separator: the whole name
"": "",
}
for room, want := range cases {
if got := workspace(room); got != want {
t.Errorf("workspace(%q) = %q, want %q", room, got, want)
}
}
}
// ── the signing material ─────────────────────────────────────────────────────
// TestKeyFileIsTheLiveKitFormat: the file is a YAML apiKey->apiSecret map, which is
// what the LiveKit server's --key-file takes. Reading the SAME file the server
// validates against is the whole point — one representation cannot drift out of sync
// with itself, and a second copy in env would mint tokens that verify against nothing.
func TestKeyFileIsTheLiveKitFormat(t *testing.T) {
// Comments and surrounding blank lines are normal in a real key file.
path := keyFileWith(t, "# livekit keys\n\n"+apiKey+": "+apiSecret+"\n")
key, secret, err := readKeys(path)
if err != nil {
t.Fatalf("readKeys: %v", err)
}
if key != apiKey || secret != apiSecret {
t.Fatalf("readKeys = (%q,%q), want (%q,%q)", key, secret, apiKey, apiSecret)
}
// And it mints against that pair end to end.
app := mountWithKeyFile(t, teamSecret, path)
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
code, tok := ask(t, app, roomIn(workspaceA), "person-42", bearer)
if code != http.StatusOK {
t.Fatalf("mint = %d %s, want 200", code, tok)
}
if claims := verify(t, tok, apiSecret); claims["iss"] != apiKey {
t.Errorf("iss = %v, want the api key from the file", claims["iss"])
}
}
// TestKeyFileRefusals is the LOUD-failure table. Every row must fail with a reason
// that names the file and the Secret, because the alternative — the bare zero value
// this used to return — is a permanent 503 with nothing in the log to chase. That is
// exactly how a Secret that is EMPTY in the cluster nearly shipped.
func TestKeyFileRefusals(t *testing.T) {
cases := []struct{ name, body string }{
{"empty file", ""},
{"comments only", "# nothing here\n"},
{"api key with no secret", apiKey + ": \"\"\n"},
{"secret with no api key", "\"\": " + apiSecret + "\n"},
// AMBIGUOUS: map iteration is random, so picking one would choose differently
// per process start and fail at the media edge intermittently.
{"two api keys", apiKey + ": " + apiSecret + "\nAPIsecond: another-secret\n"},
{"not a map", "- just\n- a list\n"},
{"whitespace-only secret", apiKey + ": \" \"\n"},
// RESTORED. This case failed once and the failure was information: it proved
// the parser was coercing scalars. Deleting it (and keeping a comment that
// claimed the opposite) turned a caught bug into a false assurance. With
// yaml.v3 a duplicated key is a hard error, which is what the LiveKit server
// does with the same bytes.
{"duplicate api key", apiKey + ": v1\n" + apiKey + ": v2\n"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
path := keyFileWith(t, c.body)
key, secret, err := readKeys(path)
if err == nil {
t.Fatalf("readKeys accepted %q -> (%q,%q); want a refusal", c.body, key, secret)
}
// The reason has to be actionable: it names the file AND the Secret.
for _, want := range []string{path, "livekit-keys", "keys.yaml"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("reason %q does not name %q", err, want)
}
}
// And it must land as an unusable state, not a half-configured one.
st := state{reason: err.Error()}
if st.ready() {
t.Error("a state with a reason reports ready")
}
})
}
}
// TestMissingKeyFileIsLoud: the file absent entirely (Secret not mounted, or mounted
// optional and not present) must name the path and the Secret, not fail silently.
func TestMissingKeyFileIsLoud(t *testing.T) {
t.Setenv("SERVER_SECRET", teamSecret)
t.Setenv(keyFileEnv, filepath.Join(t.TempDir(), "absent", "keys.yaml"))
st := load()
if st.ready() {
t.Fatal("load() reports ready with no key file")
}
for _, want := range []string{"livekit-keys", "keys.yaml", "cannot read"} {
if !strings.Contains(st.reason, want) {
t.Errorf("reason %q does not mention %q", st.reason, want)
}
}
}
// TestUnconfiguredReasonNeverReachesTheCaller: the 503 is reachable with no
// credential at all, so it must state the fact and not enumerate our secret plumbing.
// The reason belongs in the operator's log, which Mount writes.
func TestUnconfiguredReasonNeverReachesTheCaller(t *testing.T) {
app := mount(t, teamSecret, "", "")
code, body := ask(t, app, roomIn(workspaceA), "person-42", "")
if code != http.StatusServiceUnavailable {
t.Fatalf("got %d, want 503", code)
}
for _, leak := range []string{"livekit-keys", "keys.yaml", "/etc/", "SERVER_SECRET", t.TempDir()} {
if strings.Contains(body, leak) {
t.Errorf("the 503 body leaks %q: %s", leak, body)
}
}
if !strings.Contains(strings.ToLower(body), "not configured") {
t.Errorf("the 503 body does not say the office is not configured: %s", body)
}
}
// TestGrantRefusesEmptySigningKey is the crypto-boundary assertion, and it is NOT
// redundant with the ready() gate. crypto/hmac accepts an empty key and returns a
// well-formed MAC, so without this check an empty signing key produces a token that
// LOOKS correct, verifies under the empty key, and is refused by LiveKit — the exact
// silent degradation an empty Secret in the cluster would have caused. Blanking the
// key must be an error, never a token.
func TestGrantRefusesEmptySigningKey(t *testing.T) {
now := time.Now()
cases := []struct {
name string
st state
}{
{"both empty", state{}},
{"empty secret", state{apiKey: apiKey}},
{"empty api key", state{apiSecret: apiSecret}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
tok, err := c.st.grant(roomIn(workspaceA), "person-42", "Ada", now)
if err == nil {
t.Fatalf("grant minted %q with empty signing material; want a refusal", tok)
}
if tok != "" {
t.Errorf("grant returned a token alongside its error: %q", tok)
}
})
}
// The positive control: the SAME call with real material does mint, so the test
// above is discriminating between empty and present — not just always failing.
st := state{apiKey: apiKey, apiSecret: apiSecret}
if tok, err := st.grant(roomIn(workspaceA), "person-42", "Ada", now); err != nil || tok == "" {
t.Fatalf("grant with real material = (%q, %v), want a token", tok, err)
}
}
// TestKeyFileValuesAreByteExact is the property that decides whether a minted token
// verifies: the pair we sign with must be identical to the pair the LiveKit server
// read from the same bytes. So readKeys must NOT normalize — no trimming, no
// re-casing, no unquoting beyond what YAML itself does.
//
// This is the assertion that would have caught the TrimSpace I originally wrote: the
// api key is LiveKit's `iss` and the secret IS the HMAC key, so a single stripped
// space mints a token that looks perfect and verifies nowhere.
//
// The parser is gopkg.in/yaml.v3 into map[string]string — the exact library and target
// type the LiveKit server uses (livekit/pkg/config), so byte-exactness is by
// construction rather than by hope. It matters: measured on real input,
// sigs.k8s.io/yaml turned 0123456789 into "1.2345679e+08", yes into "true", 0x1f into
// "31", and silently kept the LAST of a duplicated key. Every one of those mints a
// token that verifies nowhere while the boot log says "mounted".
func TestKeyFileValuesAreByteExact(t *testing.T) {
cases := []struct{ name, body, wantKey, wantSecret string }{
{"plain scalars", "K: abc123\n", "K", "abc123"},
{"quoted, internal spaces preserved", "K: \"a b c\"\n", "K", "a b c"},
{"quoted, TRAILING space preserved", "K: \"abc \"\n", "K", "abc "},
{"quoted, LEADING space preserved", "K: \" abc\"\n", "K", " abc"},
{"base64-ish with padding", "APIxY9: aGVsbG8td29ybGQ=\n", "APIxY9", "aGVsbG8td29ybGQ="},
{"secret containing a colon", "K: \"a:b\"\n", "K", "a:b"},
// The scalars sigs.k8s.io/yaml mangled. Each of these is a token that would
// have minted cleanly and verified nowhere.
{"leading-zero digits stay a string", "K: 0123456789\n", "K", "0123456789"},
{"yes is not a bool", "K: yes\n", "K", "yes"},
{"no is not a bool", "K: no\n", "K", "no"},
{"exponent notation stays literal", "K: 1e5\n", "K", "1e5"},
{"hex notation stays literal", "K: 0x1f\n", "K", "0x1f"},
{"double zero stays literal", "K: 00\n", "K", "00"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
key, secret, err := readKeys(keyFileWith(t, c.body))
if err != nil {
t.Fatalf("readKeys: %v", err)
}
if key != c.wantKey {
t.Errorf("api key = %q, want %q (byte-exact)", key, c.wantKey)
}
if secret != c.wantSecret {
t.Errorf("api secret = %q, want %q (byte-exact)", secret, c.wantSecret)
}
})
}
}
// TestSigningUsesTheFilesSecretVerbatim closes the loop end to end: a secret with a
// trailing space must sign with THAT secret, so a verifier holding the untrimmed value
// accepts and one holding the trimmed value does not.
func TestSigningUsesTheFilesSecretVerbatim(t *testing.T) {
const padded = "sekrit-with-trailing-space "
app := mountWithKeyFile(t, teamSecret, keyFileWith(t, apiKey+": \""+padded+"\"\n"))
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
code, tok := ask(t, app, roomIn(workspaceA), "person-42", bearer)
if code != http.StatusOK {
t.Fatalf("mint = %d %s, want 200", code, tok)
}
verify(t, tok, padded) // fatals unless the untrimmed secret is the signing key
// And the trimmed variant must NOT verify — otherwise this test proves nothing.
parts := strings.Split(tok, ".")
mac := hmac.New(sha256.New, []byte(strings.TrimSpace(padded)))
mac.Write([]byte(parts[0] + "." + parts[1]))
if hmac.Equal([]byte(parts[2]), []byte(base64.RawURLEncoding.EncodeToString(mac.Sum(nil)))) {
t.Fatal("the trimmed secret also verifies — the test cannot distinguish trimming")
}
}
// ── the tenant boundary, at the level that enforces it ───────────────────────
// 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
// exact-segment to prefix survived the entire suite.
//
// Room names are chosen by the client, so this comparison is the ONLY thing standing
// between a workspace member and a room in someone else's workspace.
func TestAdmitsBindsRoomToTheSignedWorkspace(t *testing.T) {
hour := time.Now().Add(time.Hour).Unix()
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
member := func(ws string) string {
return "Bearer " + session(t, ws, teamSecret, nil, hour)
}
// A UUID cannot be a proper prefix of another UUID (token.Generate enforces
// uuid.Validate, so both are 36 chars), but the room's segment 0 is arbitrary
// client text. A PREFIX comparison would admit all of these; exact-segment does not.
for _, room := range []string{
workspaceA + "x_standup_1", // one extra char before the separator
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 {
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 {
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 {
t.Error("a member of workspace A was admitted to a workspace B room")
}
}
// TestAdmitsRefusesUnboundSession is load-bearing on its own: without the
// Workspace=="" refusal, a token that never selected a workspace (workspace claim
// empty) matches any room whose name STARTS with '_' — segment 0 is then the empty
// string and the comparison succeeds.
func TestAdmitsRefusesUnboundSession(t *testing.T) {
hour := time.Now().Add(time.Hour).Unix()
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 {
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 {
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 {
t.Fatal("a bound member was refused; the test is not discriminating")
}
}
// TestMultipleApiKeysSelectByName: a LiveKit key file is a map because a server may
// hold several keys. Ambiguity is refused, but LIVEKIT_API_KEY resolves it — so a
// multi-key file is an operator setting, not a permanent outage.
func TestMultipleApiKeysSelectByName(t *testing.T) {
body := "APIfirst: secret-one\nAPIsecond: secret-two\n"
path := keyFileWith(t, body)
// No selector ⇒ refused, and the message lists what is available AND names the
// env var to set, so the log is actionable rather than just negative.
_, _, err := readKeys(path)
if err == nil {
t.Fatal("a two-key file was accepted with no selector")
}
for _, want := range []string{"APIfirst", "APIsecond", apiKeyEnv, "livekit-keys"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("refusal %q does not mention %q", err, want)
}
}
// Selector ⇒ that exact pair, and signing uses it end to end.
t.Setenv(apiKeyEnv, "APIsecond")
key, secret, err := readKeys(path)
if err != nil {
t.Fatalf("readKeys with a selector: %v", err)
}
if key != "APIsecond" || secret != "secret-two" {
t.Fatalf("selected (%q,%q), want (APIsecond, secret-two)", key, secret)
}
app := mountWithKeyFile(t, teamSecret, path)
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
code, tok := ask(t, app, roomIn(workspaceA), "person-42", bearer)
if code != http.StatusOK {
t.Fatalf("mint = %d %s, want 200", code, tok)
}
if claims := verify(t, tok, "secret-two"); claims["iss"] != "APIsecond" {
t.Errorf("iss = %v, want APIsecond", claims["iss"])
}
// A selector naming a key the file does not have is refused, and the refusal must
// SAY SO. Dropping the membership check does not open a hole — the blank-value
// check catches it downstream — but it degrades the message to a generic "empty api
// key or secret", which sends an operator hunting the Secret's contents instead of
// the one env var that is wrong. Asserting the message keeps the diagnostic honest,
// and is what makes that mutation observable at all.
t.Setenv(apiKeyEnv, "APIabsent")
_, _, err = readKeys(path)
if err == nil {
t.Fatal("a selector naming an absent key was accepted")
}
for _, want := range []string{apiKeyEnv, "APIabsent", "APIfirst", "APIsecond"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("refusal %q does not mention %q — an operator cannot tell which knob is wrong", err, want)
}
}
}
// TestHealthSurfacesDegradation: "meet is unconfigured" has to be a dashboard fact, not
// a grep of a rotated boot log — and ready:false IS that fact. This test asserted the
// health body also carried state.reason, which named the key-file path and the Secret on
// an endpoint that takes no credential and answers on five public hosts. That was a leak
// and a second posture in a file that deliberately keeps the reason out of the getToken
// 503; the reason belongs in the boot log, which Mount writes at ERROR.
func TestHealthSurfacesDegradation(t *testing.T) {
app := mount(t, teamSecret, "", "")
req := httptest.NewRequest(http.MethodGet, "/v1/meet/health", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("GET /v1/meet/health: %v", err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("health = %d, want 503 when unconfigured", resp.StatusCode)
}
var got map[string]any
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("health body not JSON: %s", b)
}
if got["ready"] != false || got["status"] != "degraded" {
t.Errorf("health = %v, want ready:false status:degraded", got)
}
// The reason must NOT be here (TestHealthLeaksNothingUnauthenticated covers the
// full leak set); ready:false is the whole signal a probe or dashboard needs.
if _, present := got["error"]; present {
t.Errorf("health body carries the internal reason on an unauthenticated endpoint: %v", got)
}
// Configured ⇒ 200 + ready, so the probe distinguishes.
ok := mount(t, teamSecret, apiKey, apiSecret)
req2 := httptest.NewRequest(http.MethodGet, "/v1/meet/health", nil)
resp2, _ := ok.Fiber().Test(req2)
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusOK {
t.Errorf("configured health = %d, want 200", resp2.StatusCode)
}
}
// TestIdentityComesFromTheToken: LiveKit uses `sub` as the participant identity and
// EJECTS an existing participant on a duplicate. So a caller-supplied identity let any
// member of a workspace kick a colleague out of a call and impersonate them to the room.
// The signed account is the one identity the caller cannot choose.
func TestIdentityComesFromTheToken(t *testing.T) {
app := mount(t, teamSecret, apiKey, apiSecret)
bearer := session(t, workspaceA, teamSecret, nil, time.Now().Add(time.Hour).Unix())
room := roomIn(workspaceA)
// Claim a colleague's person ref in the body. It must not reach the token.
const victim = "person-victim-0001"
body, _ := json.Marshal(request{RoomName: room, ID: victim, ParticipantName: "Impostor"})
req := httptest.NewRequest(http.MethodPost, "/v1/meet/getToken", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+bearer)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("mint = %d %s, want 200", resp.StatusCode, raw)
}
claims := verify(t, string(raw), apiSecret)
if claims["sub"] == victim {
t.Fatal("the body's _id became the LiveKit identity — a member can eject and impersonate a colleague")
}
if claims["sub"] != account {
t.Fatalf("sub = %v, want the signed account %q", claims["sub"], account)
}
// Two different accounts in the same room get DIFFERENT identities, so a legitimate
// second participant is not ejected as a duplicate.
const other = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
tok2, err := token.Generate(other, workspaceA, map[string]any{"role": token.RoleMember}, time.Now().Add(time.Hour).Unix(), teamSecret)
if err != nil {
t.Fatalf("token.Generate: %v", err)
}
_, body2 := ask(t, app, room, victim, tok2)
if c2 := verify(t, body2, apiSecret); c2["sub"] != other {
t.Errorf("second participant sub = %v, want %q", c2["sub"], other)
}
}
// TestHealthLeaksNothingUnauthenticated: /v1/meet/health takes no credential and is
// reachable on five public hosts, so ready:false is the whole signal. The reason — which
// names the key file and the Secret — belongs in the boot log. Keeping it here while
// deliberately withholding it from the getToken 503 would have been two postures in one
// file.
func TestHealthLeaksNothingUnauthenticated(t *testing.T) {
for _, tc := range []struct {
name string
team, key, given string
wantCode int
}{
{"unconfigured", teamSecret, "", "", http.StatusServiceUnavailable},
{"configured", teamSecret, apiKey, apiSecret, http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
app := mount(t, tc.team, tc.key, tc.given)
req := httptest.NewRequest(http.MethodGet, "/v1/meet/health", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != tc.wantCode {
t.Errorf("health = %d, want %d", resp.StatusCode, tc.wantCode)
}
for _, leak := range []string{"livekit-keys", "keys.yaml", "/etc/", "SERVER_SECRET", apiKey, apiSecret, t.TempDir()} {
if strings.Contains(string(b), leak) {
t.Errorf("health body leaks %q: %s", leak, b)
}
}
})
}
}
+12 -3
View File
@@ -643,10 +643,19 @@ func (g *api) selectWorkspace(c *zip.Ctx, params map[string]any) error {
if st := g.entitle(c.Context(), org, role, ws.ID, account); st != nil {
return c.JSON(http.StatusPaymentRequired, map[string]any{"error": *st, "upgradeUrl": upgradeURL})
}
// Carry the tenant into the workspace token so the transactor routes to
// orgs/<org>/ws/<workspace>.db. Short-lived (workspaceTokenTTL) — it rides in
// Carry the tenant AND the caller's role into the workspace token so the
// transactor routes to orgs/<org>/ws/<workspace>.db and every downstream holder
// can tell a member from a guest. Short-lived (workspaceTokenTTL) — it rides in
// the transactor URL path, so a bounded lifetime caps replay on capture.
wsTok, err := token.Generate(account, ws.UUID, map[string]any{"org": org}, expUnix(workspaceTokenTTL), g.cfg.serverSecret)
//
// extra.role is the ONLY place a reduced principal is expressible on the wire.
// resolveWorkspace already returned it and this mint used to DROP it, so every
// consumer of a workspace token saw an owner and a guest as identical — and
// entitle() cannot help, being a billing gate that returns nil on every branch by
// design (observe mode). Signing it means clients/analytics and clients/meet
// decide capability from a verified claim, with no DB hop and no reach into this
// package's store. token.Privileged() is the one predicate that reads it.
wsTok, err := token.Generate(account, ws.UUID, map[string]any{"org": org, "role": role}, expUnix(workspaceTokenTTL), g.cfg.serverSecret)
if err != nil {
return g.fail(c, statusError("mint workspace token: "+err.Error()))
}
+62
View File
@@ -191,6 +191,68 @@ func TestSelectWorkspaceHTTP(t *testing.T) {
if err != nil || dec.Account != acct || dec.Workspace != ws.UUID || dec.Extra["org"] != org {
t.Fatalf("workspace token round-trip: %+v (err %v)", dec, err)
}
// The token must carry the caller's ROLE. Everything downstream that tells a
// member from a guest reads this claim and nothing else — clients/analytics for
// unprojected-write capability, clients/meet for a seat in a room — so a mint that
// drops it silently hands every guest an owner-shaped token.
if dec.Role() != token.RoleOwner {
t.Fatalf("workspace token role = %q, want %q", dec.Role(), token.RoleOwner)
}
if !dec.Privileged() {
t.Fatal("the workspace creator's token is not Privileged()")
}
}
// TestSelectWorkspaceSignsGuestRole is the F1 regression guard, at the MINT site.
//
// Every test of the CONSUMERS (clients/analytics, clients/meet) mints its own tokens
// with a role already set, so all of them pass whether or not selectWorkspace actually
// signs one. That is the same defect as the original inert extra.guest guards — a
// property proven against a synthesized shape — moved up one layer, and it is why
// deleting `"role": role` from the mint survived a whole mutation round. This test
// drives the REAL RPC and asserts the token a guest is handed reports itself as a guest.
func TestSelectWorkspaceSignsGuestRole(t *testing.T) {
app := mountTeam(t)
const org, owner = "acme", "550e8400-e29b-41d4-a716-446655440000"
const guest = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
ctx := context.Background()
ws, err := mounted.State.accounts.EnsureWorkspace(ctx, org, owner, "Ada")
if err != nil {
t.Fatalf("seed workspace: %v", err)
}
if err := mounted.State.accounts.AddMember(ctx, ws.ID, guest, token.RoleGuest, "Visitor"); err != nil {
t.Fatalf("seed guest member: %v", err)
}
sess, err := token.Generate(guest, "", map[string]any{"org": org}, expUnix(sessionTokenTTL), testSecret)
if err != nil {
t.Fatalf("mint session token: %v", err)
}
code, body := call(t, app, http.MethodPost, "/v1/team/account",
map[string]string{"Authorization": "Bearer " + sess},
map[string]any{"method": "selectWorkspace", "params": map[string]any{"workspaceUrl": ws.Slug}})
if code != http.StatusOK {
t.Fatalf("guest selectWorkspace status %d: %s", code, body)
}
var sw struct {
Result WorkspaceLoginInfo `json:"result"`
Error *Status `json:"error"`
}
if err := json.Unmarshal(body, &sw); err != nil || sw.Error != nil {
t.Fatalf("guest selectWorkspace = %s (err %v)", body, err)
}
dec, err := token.Decode(sw.Result.Token, testSecret, true)
if err != nil {
t.Fatalf("decode guest workspace token: %v", err)
}
if dec.Role() != token.RoleGuest {
t.Fatalf("guest token role = %q, want %q — the mint dropped or hardcoded the role", dec.Role(), token.RoleGuest)
}
if dec.Privileged() {
t.Fatal("a guest's workspace token reports Privileged() — it would get unprojected ingest and a room seat")
}
if dec.Org() != org {
t.Fatalf("guest token org = %q, want %q", dec.Org(), org)
}
}
// TestSelectWorkspaceCrossTenantBlocked proves a caller whose token org is org-b
+46
View File
@@ -68,6 +68,52 @@ type Token struct {
}
// ErrMalformed is returned when a token is not three base64url segments.
// The workspace roles a token can carry, signed into extra.role at mint
// (clients/team selectWorkspace). This is the CLOSED set validInviteRole accepts.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleGuest = "guest"
)
// claim reads a string extra claim. ONE spelling of "read a claim", so two callers
// cannot disagree about whether surrounding space counts — it does not, anywhere.
func (t *Token) claim(key string) string {
s, _ := t.Extra[key].(string)
return strings.TrimSpace(s)
}
// Org is the signed tenant claim. Every capability decision that names an org must
// come from here, never from a request body or Host.
func (t *Token) Org() string { return t.claim("org") }
// Role is the signed workspace role, or "" when the token carries none — a session
// token (no workspace chosen yet), or one minted before roles were signed.
func (t *Token) Role() string { return t.claim("role") }
// Privileged reports whether this token's role confers FULL capability on the
// workspace's data — writing rows nobody projected, minting a seat in a meeting.
//
// FAIL-CLOSED, and the closed case is the important one: an ABSENT role is NOT
// privileged. A role is only absent on a token that has not proven a workspace role
// (a pre-selectWorkspace session token) or one minted before this claim existed, and
// neither has demonstrated the thing this predicate is asked about. Guests are
// excluded by being outside the allowlist rather than by being named, so a role added
// to the invite vocabulary tomorrow starts unprivileged instead of silently full.
//
// This is the ONE predicate for reduced capability. It used to be two string
// comparisons written twice, against extra.guest/extra.readonly — claims NOTHING in
// this repo ever mints, so both copies were inert AND they disagreed about whitespace.
func (t *Token) Privileged() bool {
switch t.Role() {
case RoleOwner, RoleAdmin, RoleMember:
return true
default:
return false
}
}
var ErrMalformed = errors.New("token: malformed")
// ErrSignature is returned when HMAC verification fails.
+1 -1
View File
@@ -708,7 +708,7 @@ require (
gopkg.in/telebot.v3 v3.3.8 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.3
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect