Skip to content

v0.1.0 readiness: release pipeline, operator-plane proto contract, storage units, SDK parity - #589

Merged
aojea merged 11 commits into
google:mainfrom
aojea:ga-readiness
Oct 6, 2026
Merged

aojea merged 11 commits into
google:mainfrom
aojea:ga-readiness

Conversation

@aojea

@aojea aojea commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Pre-release review of main before v0.1.0: seven analysis passes (security, API contract, control plane, dataplane, SDK parity, tests/CI, docs) plus a build/lint/test baseline. Every finding below was verified against the code before it was fixed; the false positives from the review are listed at the end so nobody re-chases them.

Release blockers

  • The goreleaser pipeline could not have produced v0.1.0. .goreleaser.yaml still built sam-box and nano-init, whose cmd/ directories were removed after rc.8, so the tag job would have failed before any binary, npm or PyPI package was published. install.sh listed both binaries too. The test workflow now runs goreleaser check and a single-target snapshot build on every PR. Chart appVersion follows the release line.
  • The operator plane now serves api/sam.proto messages. /admin/status, /user/status, /admin/bootstrap-tokens, /user/bootstrap-tokens and /admin/enrollments encoded Go structs with json tags, internal/storage records and map[string]any, which AGENTS.md forbids on a SAM-defined surface, and which the additive-only promise after v0.1.0 would have frozen. New messages: BootstrapTokenCreateRequest/Response, BootstrapToken, EnrollmentRequest, User, EnrolledNode, RouterLease, NodeServices, AdminStatusResponse, UserStatusResponse, with *_time Timestamps and the policy embedded as PolicyConfig. Request bodies and the token response field keep their names, so the Helm bootstrap job, deploy.yaml and the bats suites are unchanged. /admin/status no longer ships each node's live biscuit and public key to the browser. api/bootstrap_token.go is gone; sam-one, the console and the tests use the generated bindings.
  • Docs described an --ext-authz-addr flag and XFCC/SPIFFE principal extraction that do not exist. The reference, the security architecture page and three concept pages now describe the credential path the code implements.

Storage

  • Every BIGINT instant is now unix milliseconds. Four tables held seconds; migration 15 converts existing rows once, behind a magnitude guard, and the round-trip test compares at millisecond precision so a table cannot fall back silently.
  • Migration 16 indexes the scans that run on every bootstrap, lease lookup, revocation pull and GC pass.
  • A ban writes the node row and the identity row in one transaction.

Correctness and parity

  • JS SDK: refresh() runs one refresh at a time, as sam-node's refreshMu and the Python SDK's lock already did; the control plane redeems only the last biscuit it issued.
  • Control plane: HTTP drain on Close is bounded (20s); the in-memory revocation set prunes on write.
  • Inference facade: a missing local authorizer is a 503, not an allow.
  • /sam/identity and /sam/peer/{id}/evidence use proto field names like every other SAM JSON surface.
  • --version on every binary; every goreleaser build and every image stamps the tag.
  • sam-console warns when --external-url is unset; sam-control-plane warns when --insecure-skip-tls-verify is set.
  • Both SDKs have an explicit test that an appended block carrying a rule, a check or a second fact beside tar_block is refused.

Review findings that turned out to be wrong

  • "rc.8 → GA upgrade fails on bootstrap token time units": both sides used seconds; schema migrations since rc.8 are additive. (The unit mix is now gone anyway.)
  • "protojson.Unmarshal drops unknown policy fields": the default rejects them.
  • "MCP pass-through skips TAR": the check is skipped only when there is no tar_block, i.e. an unattenuated token under full Block 0 RBAC.
  • "math/rand used for tokens": used for router shuffling only.

Operator notes for the release

  • rc.8 → v0.1.0 is a clean protocol break: upgrade control plane, routers and nodes together.
  • Migrations 15 and 16 run on first start; no manual step.
  • Operator-plane JSON field names changed to proto names (peer_id, expire_time, policy, ...). Scripts that only read token from a mint response are unaffected.

Verification

make build, go vet, golangci-lint, deadcode gate, verify-generated.sh, verify-sdk-generated.sh, unit tests with -race, the full integration suite, JS SDK 125/125, Python SDK 136/136, Playwright make ui-test 21/21, goreleaser check + snapshot build (all six binaries report the tag).

aojea added 9 commits October 6, 2026 17:34
…nfig in CI

cmd/sam-box and cmd/nano-init were removed in f6abfb8, after v0.1.0-rc.8,
but .goreleaser.yaml still built both, so the goreleaser job on the next tag
would fail before any binary or SDK package was published. install.sh listed
both binaries as well.

The test workflow now runs goreleaser check and a single-target snapshot
build so a stale build id fails on the pull request, not on the tag.

The chart appVersion follows the release line (0.1.0).
The reference, security architecture and concept pages described an
--ext-authz-addr flag and the extraction of a SPIFFE principal from
AttributeContext.Source.Principal or X-Forwarded-Client-Cert. sam-node has
neither: the Envoy endpoints are served on the local API listeners, and the
caller presents a Biscuit or a platform JWT that sam-node exchanges at the
control plane. The pages now name the headers the code reads and returns.
/admin/status, /user/status, /admin/bootstrap-tokens, /user/bootstrap-tokens
and /admin/enrollments encoded Go structs with json tags, internal/storage
records and map[string]any. AGENTS.md reserves every SAM-defined surface for
messages in api/sam.proto, and after v0.1.0 the proto is additive-only, so
this is the last release that can change these shapes.

New messages: BootstrapTokenCreateRequest/Response, BootstrapToken and
BootstrapTokenListResponse, EnrollmentRequest and
EnrollmentRequestListResponse, User, EnrolledNode, RouterLease,
NodeServices, AdminStatusResponse and UserStatusResponse. Instants are
Timestamps named *_time; the policy travels as an embedded PolicyConfig
instead of a JSON string; the enrollment status is the enum name. The
handlers read and write protojson with proto field names and reject
unknown fields. Field names of the request body (role, ttl_hours,
max_usages, description, owner_id, autonomous_recovery) and the `token`
in the response are unchanged, so the Helm bootstrap job, deploy.yaml and
the bats suites keep working.

/admin/status no longer carries each enrolled node's biscuit and public
key; the integration tests that compared the issued biscuit now read it
from the control plane's own store.

api/bootstrap_token.go is removed; sam-one, the console and the tests use
the generated bindings. SDK bindings regenerated.
bootstrap_tokens, enrollment_requests, users and banned_identities held
unix seconds while every other table held milliseconds. Each table was
consistent with itself, so nothing was wrong on the wire, but a query that
compared one of these columns against a millisecond clock would have been
off by a factor of a thousand with no test to notice.

Migration 15 multiplies the existing rows by 1000 behind a magnitude guard
(10^11 is the year 5138 in seconds and 1973 in milliseconds), so a row is
converted exactly once and a database that already holds milliseconds is
left alone. The round-trip test now compares at millisecond precision, so
a table falling back to seconds fails there.
The bats helpers selected enrolled nodes and routers by the Go field names
the control plane no longer serializes.
The control plane redeems only the last biscuit it issued, so two refreshes
in flight leave the loser holding a spent one and the member re-enrolling.
sam-node holds refreshMu and the Python SDK a lock; the JS SDK let a
scheduled refresh and a manual one race. refresh() now queues behind the
refresh in flight, and a failed refresh does not block the next.

The test drives eight concurrent refreshes against a fake control plane
that, like the real one, rejects a refresh presenting a stale biscuit.
…e, console warning

- The control plane's Close drained its HTTP server without a deadline; a
  stuck connection held the pod until the kubelet killed it. The drain is
  now bounded to 20s, under the default termination grace period.
- /sam/identity and /sam/peer/{id}/evidence rendered protojson with
  lowerCamelCase names while every other SAM JSON surface uses proto field
  names; they now match.
- Only sam-node reported its version. sam-control-plane, sam-router, sam-one,
  sam-console and mcp-client answer --version, every goreleaser build and
  every image build stamps the tag, and mcp-client identifies itself to MCP
  servers with the build version instead of a constant.
- sam-console warns at startup when --external-url is unset, since the OIDC
  redirect_uri and the cookie Secure flag then follow request headers.
- A ban wrote the node row and the identity row in two statements; a
  failure between them left the device banned while its owner could enroll
  a new one, or the reverse on unban. SetNodeAndIdentityBanned does both in
  one transaction and SetNodeBan uses it.
- Migration 16 indexes the scans that run on every bootstrap (/info's ban
  set), every lease lookup, every revocation pull and the node GC. nodes is
  the one table that grows with the mesh.
- The in-memory revocation set dropped expired entries only when
  /revocations was read; it now drops them on write as well.
…k grammar

- The inference facade served a local model without any authorization when
  its authorizer seam was unset. Missing wiring is now a 503, the same
  convention as the provider label verifier.
- sam-control-plane warns at startup when --insecure-skip-tls-verify is set.
- Both SDKs refuse an appended block that carries a rule, a check or a
  second fact beside tar_block; the tests now say so explicitly.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions the operator plane API to use protobuf-defined messages serialized via protojson with proto field names, replacing ad-hoc JSON structs and updating the console frontend, SDKs, CLI commands, and tests accordingly. It also introduces database schema migrations (converting timestamps to milliseconds and adding indexes), adds an atomic node and identity banning transaction, enforces local authorization checks in the OpenAI facade, and implements concurrent refresh serialization in the JS SDK. A review comment highlights a performance bottleneck in internal/controlplane/sts.go where pruning expired revoked biscuits on every write results in an O(N) map scan, suggesting a periodic or deferred cleanup strategy instead.

Comment thread internal/controlplane/sts.go
Review feedback on google#589: a full scan of the in-memory revocation set on
every write is O(N) in live revocations. The sweep now runs when the set
reaches a threshold that doubles after each pass, so a write costs O(1)
amortized and the set never holds more than twice its live entries.
@aojea

aojea commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions the operator plane (/admin/* and /user/*) to use official protobuf messages defined in api/sam.proto (such as AdminStatusResponse and UserStatusResponse) for consistent wire shapes and schema enforcement. It also introduces database migrations to standardize timestamps to milliseconds and add indexes on frequently scanned columns, implements atomic node and identity banning, and ensures the OpenAI facade fails closed if local authorization is missing. Feedback on these changes suggests validating that OIDC claims yield a valid identity key during node banning to prevent silent bypasses, and explicitly returning a 413 Request Entity Too Large status code when the request body exceeds the maximum allowed size.

if err := json.Unmarshal([]byte(node.ClaimsJSON), &claims); err != nil {
return fmt.Errorf("stored claims for %s are unreadable: %w", node.PeerID, err)
}
identity = oidcIdentityKey(claims)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

If node.ClaimsJSON is not empty (meaning the node was enrolled via OIDC), but oidcIdentityKey(claims) returns an empty string due to missing or malformed claims, identity will be empty. This means store.SetNodeAndIdentityBanned will only ban the node and silently skip banning the identity, allowing the user to potentially bypass the ban by enrolling a new node under the same identity. We should explicitly validate that a non-empty ClaimsJSON yields a valid identity key.

identity = oidcIdentityKey(claims)
		if identity == "" {
			return fmt.Errorf("stored claims for %s do not contain a valid identity key", node.PeerID)
		}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right diagnosis, but the fix belongs one step earlier. Returning an error here would run before the transaction, so a record whose claims carry no subject could not be banned at all, including the device half; the identity half is already unreachable because an identity without sub has no key to match on at /register either.

The gap was that /register accepted a JWT without sub. f8f4f03 refuses such a token (401), so every OIDC-enrolled record has a bannable identity key. SetNodeBan keeps banning the device for a record from before that check and logs a warning instead of staying silent. Covered by TestAuthDenialPaths/JWT_without_a_subject_is_rejected.

Comment thread internal/controlplane/server.go
…rsized bodies

Review feedback on google#589. Bans and ownership are keyed on issuer|subject, so
a token without a sub claim would enroll an identity that can never be
banned; /register now refuses it (OIDC ID tokens, Kubernetes service account
tokens and JWT-SVIDs all carry one). SetNodeBan still bans the device when a
record from before that check yields no identity key, and says so in the
log instead of silently.

readProtoJSON answers 413 rather than 400 when the body exceeds the limit.
@aojea

aojea commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the operator plane API to use structured Protobuf messages and 'protojson' serialization instead of ad-hoc JSON structures or internal storage types. It removes the 'sam-box' and 'nano-init' binaries, adds a '-version' flag to all main commands, and implements version injection during builds. On the storage side, migrations 15 and 16 are added to convert timestamp storage from seconds to milliseconds and to introduce database indexes for performance. Additionally, a new atomic 'SetNodeAndIdentityBanned' transaction is introduced to prevent inconsistent ban states, local authorization in the OpenAI facade is updated to fail closed if unconfigured, and concurrent SDK token refreshes are serialized to prevent race conditions. Documentation and tests have been updated accordingly. I have no feedback to provide as no review comments were submitted.

@aojea
aojea merged commit f40c348 into google:main Oct 6, 2026
23 checks passed
aojea added a commit that referenced this pull request Oct 6, 2026
Review feedback on #589: a full scan of the in-memory revocation set on
every write is O(N) in live revocations. The sweep now runs when the set
reaches a threshold that doubles after each pass, so a write costs O(1)
amortized and the set never holds more than twice its live entries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant