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:
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user