diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 90804bb3..d7ef6187 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -68,6 +68,7 @@ jobs: with: context: . file: Dockerfile.sam-control-plane + build-args: VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }} platforms: ${{ github.ref_type == 'tag' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} push: true tags: ${{ steps.meta-cp.outputs.tags }} @@ -136,6 +137,7 @@ jobs: with: context: . file: Dockerfile.sam-console + build-args: VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }} platforms: ${{ github.ref_type == 'tag' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} push: true tags: ${{ steps.meta-console.outputs.tags }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 098ddc0a..bd603a82 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -67,6 +67,30 @@ jobs: run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - run: make verify + # A build id whose cmd/ directory no longer exists only fails on the tag + # push, after the release is already underway; catch it on the PR instead. + goreleaser-check: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + distribution: goreleaser + version: "~> v2" + args: check + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + distribution: goreleaser + version: "~> v2" + args: build --snapshot --clean --single-target + helm-lint: runs-on: ubuntu-latest steps: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index db1cbeb3..c8bc8e0f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,7 +31,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w + - -s -w -X github.com/google/sam/internal/version.Version={{ .Tag }} - id: sam-router main: ./cmd/sam-router binary: sam-router @@ -59,21 +59,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w - - id: sam-box - main: ./cmd/sam-box - binary: sam-box - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm64 - ldflags: - - -s -w + - -s -w -X github.com/google/sam/internal/version.Version={{ .Tag }} - id: sam-console main: ./cmd/sam-console binary: sam-console @@ -87,7 +73,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w + - -s -w -X github.com/google/sam/internal/version.Version={{ .Tag }} - id: sam-one main: ./cmd/sam-one binary: sam-one @@ -102,23 +88,6 @@ builds: - arm64 ldflags: - -s -w -X github.com/google/sam/internal/version.Version={{ .Tag }} - - id: nano-init - main: . - # nano-init is its own module: it carries a userspace TCP stack, and that - # dependency has no business in the graph every other binary builds from. - dir: ./cmd/nano-init - binary: nano-init - env: - - CGO_ENABLED=0 - # linux only: nano-init is PID 1 inside a Linux sandbox — TUN ioctls, - # netlink, namespaces and vsock have no meaning anywhere else. - goos: - - linux - goarch: - - amd64 - - arm64 - ldflags: - - -s -w archives: - formats: - tar.gz @@ -133,6 +102,3 @@ archives: - goos: windows formats: - zip - # nano-init only builds for linux (see build id "nano-init" above), so the - # windows and darwin archives intentionally have one fewer binary. - allow_different_binary_count: true diff --git a/Dockerfile.sam-console b/Dockerfile.sam-console index dd807dbd..84c0ee98 100644 --- a/Dockerfile.sam-console +++ b/Dockerfile.sam-console @@ -5,7 +5,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o /bin/sam-console ./cmd/sam-console +ARG VERSION=devel +RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-X github.com/google/sam/internal/version.Version=${VERSION}" -o /bin/sam-console ./cmd/sam-console FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab diff --git a/Dockerfile.sam-control-plane b/Dockerfile.sam-control-plane index f7a92eab..21c39c3d 100644 --- a/Dockerfile.sam-control-plane +++ b/Dockerfile.sam-control-plane @@ -4,7 +4,8 @@ WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o sam-control-plane ./cmd/sam-control-plane +ARG VERSION=devel +RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -ldflags="-X github.com/google/sam/internal/version.Version=${VERSION}" -o sam-control-plane ./cmd/sam-control-plane # Pre-create the sqlite data dir owned by the nonroot UID so that a fresh # named/anonymous volume mounted over it inherits writable ownership instead # of defaulting to root. diff --git a/Makefile b/Makefile index 6312e16c..6a135644 100644 --- a/Makefile +++ b/Makefile @@ -259,7 +259,7 @@ update: go mod tidy docker-build-control-plane: - docker build --load -t sam-control-plane:local -f Dockerfile.sam-control-plane . + docker build --load --build-arg VERSION="$(VERSION)" -t sam-control-plane:local -f Dockerfile.sam-control-plane . docker-build-router: docker build --load --build-arg VERSION="$(VERSION)" -t sam-router:local -f Dockerfile.sam-router . @@ -274,7 +274,7 @@ docker-build-e2e-runtime: docker build --load -t sam-e2e-runtime:local -f tests/e2e/docker/Dockerfile.sam-runtime . docker-build-sam-console: - docker build --load -t sam-console:local -f Dockerfile.sam-console . + docker build --load --build-arg VERSION="$(VERSION)" -t sam-console:local -f Dockerfile.sam-console . docker-build: docker-build-control-plane docker-build-router docker-build-node docker-build-mock-oidc docker-build-e2e-runtime docker-build-sam-console diff --git a/api/bootstrap_token.go b/api/bootstrap_token.go deleted file mode 100644 index 03b05a87..00000000 --- a/api/bootstrap_token.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2026 Google LLC -// -// 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 api - -// BootstrapTokenRequest is the JSON body that mints a bootstrap token, on -// POST /admin/bootstrap-tokens (admin bearer) and POST /users/me/tokens -// (OIDC user). It is the one definition both the control plane and its -// clients (sam-one's CLI, the console) marshal, so a field name exists in -// exactly one place. -type BootstrapTokenRequest struct { - // Role the token enrolls into, e.g. RoleNode. Required on the admin - // endpoint; the user endpoint defaults it to RoleNode. - Role string `json:"role"` - // OwnerID is the user the token is issued on behalf of. Honored by the - // user endpoint only, and only for admins; defaults to the caller. - OwnerID string `json:"owner_id,omitempty"` - // TTLHours bounds the token's validity; the control plane defaults a - // non-positive value to 24. - TTLHours int `json:"ttl_hours"` - // MaxUsages is how many enrollments the token admits; the control plane - // defaults a non-positive value to 1. - MaxUsages int `json:"max_usages"` - // Description is a free-form operator note stored with the token. - Description string `json:"description,omitempty"` - // AutonomousRecovery is copied onto every node the token enrolls: such a - // node may still refresh its credential after the control plane's - // signing key rotated past its grace period, on proof of possession of - // its own key alone. Admin-only, because a node that can always recover - // holds a credential that never expires (see - // storage.EnrolledNode.AutonomousRecovery). - AutonomousRecovery bool `json:"autonomous_recovery"` -} - -// BootstrapTokenResponse is returned (201) when a bootstrap token is minted. -// Token is the plaintext and is shown exactly once; the control plane keeps -// only its hash, which is also the ID. -type BootstrapTokenResponse struct { - ID string `json:"id"` - Token string `json:"token"` - Role string `json:"role"` - OwnerID string `json:"owner_id,omitempty"` - ExpiresAt string `json:"expires_at"` -} diff --git a/api/sam.pb.go b/api/sam.pb.go index 6a78d874..e2264df2 100644 --- a/api/sam.pb.go +++ b/api/sam.pb.go @@ -3263,6 +3263,1008 @@ func (x *TokenRevokeResponse) GetError() string { return "" } +// BootstrapTokenCreateRequest is the body of POST /admin/bootstrap-tokens +// (admin bearer) and POST /user/bootstrap-tokens (mesh user). +type BootstrapTokenCreateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Role the token enrolls into, e.g. "sam:role:node". Required on the admin + // endpoint; the user endpoint defaults it to "sam:role:node". + Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` + // User the token is issued on behalf of. Honored by the user endpoint + // only, and only for admins; defaults to the caller. + OwnerId string `protobuf:"bytes,2,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + // How long the token stays valid; unset or non-positive means 24. + TtlHours int32 `protobuf:"varint,3,opt,name=ttl_hours,json=ttlHours,proto3" json:"ttl_hours,omitempty"` + // How many enrollments the token admits; unset or non-positive means 1. + MaxUsages int32 `protobuf:"varint,4,opt,name=max_usages,json=maxUsages,proto3" json:"max_usages,omitempty"` + // Free-form operator note stored with the token. + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` + // Copied onto every node the token enrolls: such a node may still refresh + // its credential after the control plane's signing key rotated past its + // grace period, on proof of possession of its own key alone. Admin-only, + // because a node that can always recover holds a credential that never + // expires. + AutonomousRecovery bool `protobuf:"varint,6,opt,name=autonomous_recovery,json=autonomousRecovery,proto3" json:"autonomous_recovery,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BootstrapTokenCreateRequest) Reset() { + *x = BootstrapTokenCreateRequest{} + mi := &file_api_sam_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BootstrapTokenCreateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BootstrapTokenCreateRequest) ProtoMessage() {} + +func (x *BootstrapTokenCreateRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BootstrapTokenCreateRequest.ProtoReflect.Descriptor instead. +func (*BootstrapTokenCreateRequest) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{40} +} + +func (x *BootstrapTokenCreateRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *BootstrapTokenCreateRequest) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *BootstrapTokenCreateRequest) GetTtlHours() int32 { + if x != nil { + return x.TtlHours + } + return 0 +} + +func (x *BootstrapTokenCreateRequest) GetMaxUsages() int32 { + if x != nil { + return x.MaxUsages + } + return 0 +} + +func (x *BootstrapTokenCreateRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *BootstrapTokenCreateRequest) GetAutonomousRecovery() bool { + if x != nil { + return x.AutonomousRecovery + } + return false +} + +// BootstrapTokenCreateResponse is returned (201) when a token is minted. +// token is the plaintext and is shown exactly once; the control plane keeps +// only its hash, which is also the id. +type BootstrapTokenCreateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"` + OwnerId string `protobuf:"bytes,4,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BootstrapTokenCreateResponse) Reset() { + *x = BootstrapTokenCreateResponse{} + mi := &file_api_sam_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BootstrapTokenCreateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BootstrapTokenCreateResponse) ProtoMessage() {} + +func (x *BootstrapTokenCreateResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BootstrapTokenCreateResponse.ProtoReflect.Descriptor instead. +func (*BootstrapTokenCreateResponse) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{41} +} + +func (x *BootstrapTokenCreateResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *BootstrapTokenCreateResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *BootstrapTokenCreateResponse) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *BootstrapTokenCreateResponse) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *BootstrapTokenCreateResponse) GetExpireTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpireTime + } + return nil +} + +// BootstrapToken is a minted token as operators list it. +type BootstrapToken struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` + OwnerId string `protobuf:"bytes,3,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + MaxUsages int32 `protobuf:"varint,4,opt,name=max_usages,json=maxUsages,proto3" json:"max_usages,omitempty"` + UsagesCount int32 `protobuf:"varint,5,opt,name=usages_count,json=usagesCount,proto3" json:"usages_count,omitempty"` + Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"` + CreateTime *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` + // Set when an operator revoked the token, which is distinct from expiry + // or exhausted usages. Unset means never revoked. + RevokeTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=revoke_time,json=revokeTime,proto3" json:"revoke_time,omitempty"` + AutonomousRecovery bool `protobuf:"varint,10,opt,name=autonomous_recovery,json=autonomousRecovery,proto3" json:"autonomous_recovery,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BootstrapToken) Reset() { + *x = BootstrapToken{} + mi := &file_api_sam_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BootstrapToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BootstrapToken) ProtoMessage() {} + +func (x *BootstrapToken) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BootstrapToken.ProtoReflect.Descriptor instead. +func (*BootstrapToken) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{42} +} + +func (x *BootstrapToken) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *BootstrapToken) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *BootstrapToken) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *BootstrapToken) GetMaxUsages() int32 { + if x != nil { + return x.MaxUsages + } + return 0 +} + +func (x *BootstrapToken) GetUsagesCount() int32 { + if x != nil { + return x.UsagesCount + } + return 0 +} + +func (x *BootstrapToken) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *BootstrapToken) GetCreateTime() *timestamppb.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *BootstrapToken) GetExpireTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpireTime + } + return nil +} + +func (x *BootstrapToken) GetRevokeTime() *timestamppb.Timestamp { + if x != nil { + return x.RevokeTime + } + return nil +} + +func (x *BootstrapToken) GetAutonomousRecovery() bool { + if x != nil { + return x.AutonomousRecovery + } + return false +} + +type BootstrapTokenListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tokens []*BootstrapToken `protobuf:"bytes,1,rep,name=tokens,proto3" json:"tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BootstrapTokenListResponse) Reset() { + *x = BootstrapTokenListResponse{} + mi := &file_api_sam_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BootstrapTokenListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BootstrapTokenListResponse) ProtoMessage() {} + +func (x *BootstrapTokenListResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BootstrapTokenListResponse.ProtoReflect.Descriptor instead. +func (*BootstrapTokenListResponse) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{43} +} + +func (x *BootstrapTokenListResponse) GetTokens() []*BootstrapToken { + if x != nil { + return x.Tokens + } + return nil +} + +// EnrollmentRequest is a bootstrap enrollment awaiting or past an operator +// decision (see BootstrapEnrollRequest). +type EnrollmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + PeerId string `protobuf:"bytes,2,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + // The bootstrap token the request was made with. + TokenId string `protobuf:"bytes,3,opt,name=token_id,json=tokenId,proto3" json:"token_id,omitempty"` + Status EnrollmentStatus `protobuf:"varint,4,opt,name=status,proto3,enum=sam.v1.EnrollmentStatus" json:"status,omitempty"` + // Labels the node declared; approval attests them into its biscuit. + Labels map[string]string `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + CreateTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + // Unset while the request is pending. + ResolveTime *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=resolve_time,json=resolveTime,proto3" json:"resolve_time,omitempty"` + ResolvedBy string `protobuf:"bytes,8,opt,name=resolved_by,json=resolvedBy,proto3" json:"resolved_by,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnrollmentRequest) Reset() { + *x = EnrollmentRequest{} + mi := &file_api_sam_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnrollmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnrollmentRequest) ProtoMessage() {} + +func (x *EnrollmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnrollmentRequest.ProtoReflect.Descriptor instead. +func (*EnrollmentRequest) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{44} +} + +func (x *EnrollmentRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *EnrollmentRequest) GetPeerId() string { + if x != nil { + return x.PeerId + } + return "" +} + +func (x *EnrollmentRequest) GetTokenId() string { + if x != nil { + return x.TokenId + } + return "" +} + +func (x *EnrollmentRequest) GetStatus() EnrollmentStatus { + if x != nil { + return x.Status + } + return EnrollmentStatus_ENROLLMENT_STATUS_UNSPECIFIED +} + +func (x *EnrollmentRequest) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *EnrollmentRequest) GetCreateTime() *timestamppb.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *EnrollmentRequest) GetResolveTime() *timestamppb.Timestamp { + if x != nil { + return x.ResolveTime + } + return nil +} + +func (x *EnrollmentRequest) GetResolvedBy() string { + if x != nil { + return x.ResolvedBy + } + return "" +} + +type EnrollmentRequestListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Requests []*EnrollmentRequest `protobuf:"bytes,1,rep,name=requests,proto3" json:"requests,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnrollmentRequestListResponse) Reset() { + *x = EnrollmentRequestListResponse{} + mi := &file_api_sam_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnrollmentRequestListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnrollmentRequestListResponse) ProtoMessage() {} + +func (x *EnrollmentRequestListResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnrollmentRequestListResponse.ProtoReflect.Descriptor instead. +func (*EnrollmentRequestListResponse) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{45} +} + +func (x *EnrollmentRequestListResponse) GetRequests() []*EnrollmentRequest { + if x != nil { + return x.Requests + } + return nil +} + +// User is a human identity known to the mesh. +type User struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The identity provider's subject. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Issuer string `protobuf:"bytes,2,opt,name=issuer,proto3" json:"issuer,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + // "admin" or "user". + Role string `protobuf:"bytes,4,opt,name=role,proto3" json:"role,omitempty"` + CreateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *User) Reset() { + *x = User{} + mi := &file_api_sam_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{46} +} + +func (x *User) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *User) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +func (x *User) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *User) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *User) GetCreateTime() *timestamppb.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +// EnrolledNode is a member's enrollment record without its credential. +type EnrolledNode struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` + // How the node enrolled, e.g. "oidc" or "bootstrap". + EnrollmentType string `protobuf:"bytes,3,opt,name=enrollment_type,json=enrollmentType,proto3" json:"enrollment_type,omitempty"` + // The identity provider's claims as stored at enrollment. Admin-only. + ClaimsJson string `protobuf:"bytes,4,opt,name=claims_json,json=claimsJson,proto3" json:"claims_json,omitempty"` + OwnerId string `protobuf:"bytes,5,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + Labels map[string]string `protobuf:"bytes,6,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + EnrollTime *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=enroll_time,json=enrollTime,proto3" json:"enroll_time,omitempty"` + // When the enrollment session ends; unset means it does not expire. + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` + Banned bool `protobuf:"varint,9,opt,name=banned,proto3" json:"banned,omitempty"` + AutonomousRecovery bool `protobuf:"varint,10,opt,name=autonomous_recovery,json=autonomousRecovery,proto3" json:"autonomous_recovery,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnrolledNode) Reset() { + *x = EnrolledNode{} + mi := &file_api_sam_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnrolledNode) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnrolledNode) ProtoMessage() {} + +func (x *EnrolledNode) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnrolledNode.ProtoReflect.Descriptor instead. +func (*EnrolledNode) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{47} +} + +func (x *EnrolledNode) GetPeerId() string { + if x != nil { + return x.PeerId + } + return "" +} + +func (x *EnrolledNode) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *EnrolledNode) GetEnrollmentType() string { + if x != nil { + return x.EnrollmentType + } + return "" +} + +func (x *EnrolledNode) GetClaimsJson() string { + if x != nil { + return x.ClaimsJson + } + return "" +} + +func (x *EnrolledNode) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *EnrolledNode) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *EnrolledNode) GetEnrollTime() *timestamppb.Timestamp { + if x != nil { + return x.EnrollTime + } + return nil +} + +func (x *EnrolledNode) GetExpireTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpireTime + } + return nil +} + +func (x *EnrolledNode) GetBanned() bool { + if x != nil { + return x.Banned + } + return false +} + +func (x *EnrolledNode) GetAutonomousRecovery() bool { + if x != nil { + return x.AutonomousRecovery + } + return false +} + +// RouterLease is a router's current registration with the control plane. +type RouterLease struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + // Multiaddrs, `/p2p/` suffixed. + Addresses []string `protobuf:"bytes,2,rep,name=addresses,proto3" json:"addresses,omitempty"` + LastRenewalTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=last_renewal_time,json=lastRenewalTime,proto3" json:"last_renewal_time,omitempty"` + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` + ConnectedPeers []string `protobuf:"bytes,5,rep,name=connected_peers,json=connectedPeers,proto3" json:"connected_peers,omitempty"` + DhtSize int32 `protobuf:"varint,6,opt,name=dht_size,json=dhtSize,proto3" json:"dht_size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RouterLease) Reset() { + *x = RouterLease{} + mi := &file_api_sam_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RouterLease) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RouterLease) ProtoMessage() {} + +func (x *RouterLease) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RouterLease.ProtoReflect.Descriptor instead. +func (*RouterLease) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{48} +} + +func (x *RouterLease) GetPeerId() string { + if x != nil { + return x.PeerId + } + return "" +} + +func (x *RouterLease) GetAddresses() []string { + if x != nil { + return x.Addresses + } + return nil +} + +func (x *RouterLease) GetLastRenewalTime() *timestamppb.Timestamp { + if x != nil { + return x.LastRenewalTime + } + return nil +} + +func (x *RouterLease) GetExpireTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpireTime + } + return nil +} + +func (x *RouterLease) GetConnectedPeers() []string { + if x != nil { + return x.ConnectedPeers + } + return nil +} + +func (x *RouterLease) GetDhtSize() int32 { + if x != nil { + return x.DhtSize + } + return 0 +} + +// NodeServices is the services one node last reported (see +// NodeCatalogReport). +type NodeServices struct { + state protoimpl.MessageState `protogen:"open.v1"` + Services []*ServiceInfo `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"` + ReportTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=report_time,json=reportTime,proto3" json:"report_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NodeServices) Reset() { + *x = NodeServices{} + mi := &file_api_sam_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NodeServices) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NodeServices) ProtoMessage() {} + +func (x *NodeServices) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NodeServices.ProtoReflect.Descriptor instead. +func (*NodeServices) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{49} +} + +func (x *NodeServices) GetServices() []*ServiceInfo { + if x != nil { + return x.Services + } + return nil +} + +func (x *NodeServices) GetReportTime() *timestamppb.Timestamp { + if x != nil { + return x.ReportTime + } + return nil +} + +// AdminStatusResponse answers GET /admin/status: everything the console +// shows an administrator. +type AdminStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Users []*User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` + ActiveRouters []*RouterLease `protobuf:"bytes,2,rep,name=active_routers,json=activeRouters,proto3" json:"active_routers,omitempty"` + EnrolledNodes []*EnrolledNode `protobuf:"bytes,3,rep,name=enrolled_nodes,json=enrolledNodes,proto3" json:"enrolled_nodes,omitempty"` + EnrollmentRequests []*EnrollmentRequest `protobuf:"bytes,4,rep,name=enrollment_requests,json=enrollmentRequests,proto3" json:"enrollment_requests,omitempty"` + BootstrapTokens []*BootstrapToken `protobuf:"bytes,5,rep,name=bootstrap_tokens,json=bootstrapTokens,proto3" json:"bootstrap_tokens,omitempty"` + Policy *PolicyConfig `protobuf:"bytes,6,opt,name=policy,proto3" json:"policy,omitempty"` + // Keyed by the reporting node's peer ID; admitted nodes only. + NodeCatalog map[string]*NodeServices `protobuf:"bytes,7,rep,name=node_catalog,json=nodeCatalog,proto3" json:"node_catalog,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminStatusResponse) Reset() { + *x = AdminStatusResponse{} + mi := &file_api_sam_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminStatusResponse) ProtoMessage() {} + +func (x *AdminStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminStatusResponse.ProtoReflect.Descriptor instead. +func (*AdminStatusResponse) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{50} +} + +func (x *AdminStatusResponse) GetUsers() []*User { + if x != nil { + return x.Users + } + return nil +} + +func (x *AdminStatusResponse) GetActiveRouters() []*RouterLease { + if x != nil { + return x.ActiveRouters + } + return nil +} + +func (x *AdminStatusResponse) GetEnrolledNodes() []*EnrolledNode { + if x != nil { + return x.EnrolledNodes + } + return nil +} + +func (x *AdminStatusResponse) GetEnrollmentRequests() []*EnrollmentRequest { + if x != nil { + return x.EnrollmentRequests + } + return nil +} + +func (x *AdminStatusResponse) GetBootstrapTokens() []*BootstrapToken { + if x != nil { + return x.BootstrapTokens + } + return nil +} + +func (x *AdminStatusResponse) GetPolicy() *PolicyConfig { + if x != nil { + return x.Policy + } + return nil +} + +func (x *AdminStatusResponse) GetNodeCatalog() map[string]*NodeServices { + if x != nil { + return x.NodeCatalog + } + return nil +} + +// UserStatusResponse answers GET /user/status: the caller and what it owns. +// The router fleet and the mesh policy describe the whole mesh and are set +// for an administrator only. +type UserStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + EnrolledNodes []*EnrolledNode `protobuf:"bytes,2,rep,name=enrolled_nodes,json=enrolledNodes,proto3" json:"enrolled_nodes,omitempty"` + BootstrapTokens []*BootstrapToken `protobuf:"bytes,3,rep,name=bootstrap_tokens,json=bootstrapTokens,proto3" json:"bootstrap_tokens,omitempty"` + ActiveRouters []*RouterLease `protobuf:"bytes,4,rep,name=active_routers,json=activeRouters,proto3" json:"active_routers,omitempty"` + Policy *PolicyConfig `protobuf:"bytes,5,opt,name=policy,proto3" json:"policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserStatusResponse) Reset() { + *x = UserStatusResponse{} + mi := &file_api_sam_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserStatusResponse) ProtoMessage() {} + +func (x *UserStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserStatusResponse.ProtoReflect.Descriptor instead. +func (*UserStatusResponse) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{51} +} + +func (x *UserStatusResponse) GetUser() *User { + if x != nil { + return x.User + } + return nil +} + +func (x *UserStatusResponse) GetEnrolledNodes() []*EnrolledNode { + if x != nil { + return x.EnrolledNodes + } + return nil +} + +func (x *UserStatusResponse) GetBootstrapTokens() []*BootstrapToken { + if x != nil { + return x.BootstrapTokens + } + return nil +} + +func (x *UserStatusResponse) GetActiveRouters() []*RouterLease { + if x != nil { + return x.ActiveRouters + } + return nil +} + +func (x *UserStatusResponse) GetPolicy() *PolicyConfig { + if x != nil { + return x.Policy + } + return nil +} + type IdentityEvidenceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` @@ -3277,7 +4279,7 @@ type IdentityEvidenceResponse struct { func (x *IdentityEvidenceResponse) Reset() { *x = IdentityEvidenceResponse{} - mi := &file_api_sam_proto_msgTypes[40] + mi := &file_api_sam_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3289,7 +4291,7 @@ func (x *IdentityEvidenceResponse) String() string { func (*IdentityEvidenceResponse) ProtoMessage() {} func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[40] + mi := &file_api_sam_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3302,7 +4304,7 @@ func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use IdentityEvidenceResponse.ProtoReflect.Descriptor instead. func (*IdentityEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{40} + return file_api_sam_proto_rawDescGZIP(), []int{52} } func (x *IdentityEvidenceResponse) GetPeerId() string { @@ -3363,7 +4365,7 @@ type PeerEvidenceResponse struct { func (x *PeerEvidenceResponse) Reset() { *x = PeerEvidenceResponse{} - mi := &file_api_sam_proto_msgTypes[41] + mi := &file_api_sam_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3375,7 +4377,7 @@ func (x *PeerEvidenceResponse) String() string { func (*PeerEvidenceResponse) ProtoMessage() {} func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[41] + mi := &file_api_sam_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3388,7 +4390,7 @@ func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerEvidenceResponse.ProtoReflect.Descriptor instead. func (*PeerEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{41} + return file_api_sam_proto_rawDescGZIP(), []int{53} } func (x *PeerEvidenceResponse) GetPeerId() string { @@ -3473,7 +4475,7 @@ type MemberCredential struct { func (x *MemberCredential) Reset() { *x = MemberCredential{} - mi := &file_api_sam_proto_msgTypes[42] + mi := &file_api_sam_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3485,7 +4487,7 @@ func (x *MemberCredential) String() string { func (*MemberCredential) ProtoMessage() {} func (x *MemberCredential) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[42] + mi := &file_api_sam_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3498,7 +4500,7 @@ func (x *MemberCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use MemberCredential.ProtoReflect.Descriptor instead. func (*MemberCredential) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{42} + return file_api_sam_proto_rawDescGZIP(), []int{54} } func (x *MemberCredential) GetControlPlaneUrl() string { @@ -3563,7 +4565,7 @@ type TrustedSigningKey struct { func (x *TrustedSigningKey) Reset() { *x = TrustedSigningKey{} - mi := &file_api_sam_proto_msgTypes[43] + mi := &file_api_sam_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3575,7 +4577,7 @@ func (x *TrustedSigningKey) String() string { func (*TrustedSigningKey) ProtoMessage() {} func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[43] + mi := &file_api_sam_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3588,7 +4590,7 @@ func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TrustedSigningKey.ProtoReflect.Descriptor instead. func (*TrustedSigningKey) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{43} + return file_api_sam_proto_rawDescGZIP(), []int{55} } func (x *TrustedSigningKey) GetPublicKey() []byte { @@ -3617,7 +4619,7 @@ type OIDCSession struct { func (x *OIDCSession) Reset() { *x = OIDCSession{} - mi := &file_api_sam_proto_msgTypes[44] + mi := &file_api_sam_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3629,7 +4631,7 @@ func (x *OIDCSession) String() string { func (*OIDCSession) ProtoMessage() {} func (x *OIDCSession) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[44] + mi := &file_api_sam_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3642,7 +4644,7 @@ func (x *OIDCSession) ProtoReflect() protoreflect.Message { // Deprecated: Use OIDCSession.ProtoReflect.Descriptor instead. func (*OIDCSession) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{44} + return file_api_sam_proto_rawDescGZIP(), []int{56} } func (x *OIDCSession) GetIssuer() string { @@ -3694,7 +4696,7 @@ type TaskAuthorizationRule struct { func (x *TaskAuthorizationRule) Reset() { *x = TaskAuthorizationRule{} - mi := &file_api_sam_proto_msgTypes[45] + mi := &file_api_sam_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3706,7 +4708,7 @@ func (x *TaskAuthorizationRule) String() string { func (*TaskAuthorizationRule) ProtoMessage() {} func (x *TaskAuthorizationRule) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[45] + mi := &file_api_sam_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3719,7 +4721,7 @@ func (x *TaskAuthorizationRule) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskAuthorizationRule.ProtoReflect.Descriptor instead. func (*TaskAuthorizationRule) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{45} + return file_api_sam_proto_rawDescGZIP(), []int{57} } func (x *TaskAuthorizationRule) GetName() string { @@ -3771,7 +4773,7 @@ type TaskRule struct { func (x *TaskRule) Reset() { *x = TaskRule{} - mi := &file_api_sam_proto_msgTypes[46] + mi := &file_api_sam_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3783,7 +4785,7 @@ func (x *TaskRule) String() string { func (*TaskRule) ProtoMessage() {} func (x *TaskRule) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[46] + mi := &file_api_sam_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3796,7 +4798,7 @@ func (x *TaskRule) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskRule.ProtoReflect.Descriptor instead. func (*TaskRule) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{46} + return file_api_sam_proto_rawDescGZIP(), []int{58} } func (x *TaskRule) GetDescription() string { @@ -3845,7 +4847,7 @@ type TaskOperation struct { func (x *TaskOperation) Reset() { *x = TaskOperation{} - mi := &file_api_sam_proto_msgTypes[47] + mi := &file_api_sam_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3857,7 +4859,7 @@ func (x *TaskOperation) String() string { func (*TaskOperation) ProtoMessage() {} func (x *TaskOperation) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[47] + mi := &file_api_sam_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3870,7 +4872,7 @@ func (x *TaskOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskOperation.ProtoReflect.Descriptor instead. func (*TaskOperation) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{47} + return file_api_sam_proto_rawDescGZIP(), []int{59} } func (x *TaskOperation) GetAllowedTools() []string { @@ -3930,7 +4932,7 @@ type TokenExchangeRequest struct { func (x *TokenExchangeRequest) Reset() { *x = TokenExchangeRequest{} - mi := &file_api_sam_proto_msgTypes[48] + mi := &file_api_sam_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3942,7 +4944,7 @@ func (x *TokenExchangeRequest) String() string { func (*TokenExchangeRequest) ProtoMessage() {} func (x *TokenExchangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[48] + mi := &file_api_sam_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3955,7 +4957,7 @@ func (x *TokenExchangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenExchangeRequest.ProtoReflect.Descriptor instead. func (*TokenExchangeRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{48} + return file_api_sam_proto_rawDescGZIP(), []int{60} } func (x *TokenExchangeRequest) GetSubjectToken() string { @@ -4005,7 +5007,7 @@ type TokenExchangeResponse struct { func (x *TokenExchangeResponse) Reset() { *x = TokenExchangeResponse{} - mi := &file_api_sam_proto_msgTypes[49] + mi := &file_api_sam_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4017,7 +5019,7 @@ func (x *TokenExchangeResponse) String() string { func (*TokenExchangeResponse) ProtoMessage() {} func (x *TokenExchangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[49] + mi := &file_api_sam_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4030,7 +5032,7 @@ func (x *TokenExchangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenExchangeResponse.ProtoReflect.Descriptor instead. func (*TokenExchangeResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{49} + return file_api_sam_proto_rawDescGZIP(), []int{61} } func (x *TokenExchangeResponse) GetBiscuitToken() []byte { @@ -4086,7 +5088,7 @@ type STSTokenRequest struct { func (x *STSTokenRequest) Reset() { *x = STSTokenRequest{} - mi := &file_api_sam_proto_msgTypes[50] + mi := &file_api_sam_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4098,7 +5100,7 @@ func (x *STSTokenRequest) String() string { func (*STSTokenRequest) ProtoMessage() {} func (x *STSTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[50] + mi := &file_api_sam_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4111,7 +5113,7 @@ func (x *STSTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use STSTokenRequest.ProtoReflect.Descriptor instead. func (*STSTokenRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{50} + return file_api_sam_proto_rawDescGZIP(), []int{62} } func (x *STSTokenRequest) GetBiscuit() []byte { @@ -4162,7 +5164,7 @@ type STSTokenResponse struct { func (x *STSTokenResponse) Reset() { *x = STSTokenResponse{} - mi := &file_api_sam_proto_msgTypes[51] + mi := &file_api_sam_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4174,7 +5176,7 @@ func (x *STSTokenResponse) String() string { func (*STSTokenResponse) ProtoMessage() {} func (x *STSTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[51] + mi := &file_api_sam_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4187,7 +5189,7 @@ func (x *STSTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use STSTokenResponse.ProtoReflect.Descriptor instead. func (*STSTokenResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{51} + return file_api_sam_proto_rawDescGZIP(), []int{63} } func (x *STSTokenResponse) GetJwt() string { @@ -4238,7 +5240,7 @@ type RevocationsResponse struct { func (x *RevocationsResponse) Reset() { *x = RevocationsResponse{} - mi := &file_api_sam_proto_msgTypes[52] + mi := &file_api_sam_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4250,7 +5252,7 @@ func (x *RevocationsResponse) String() string { func (*RevocationsResponse) ProtoMessage() {} func (x *RevocationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[52] + mi := &file_api_sam_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4263,7 +5265,7 @@ func (x *RevocationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevocationsResponse.ProtoReflect.Descriptor instead. func (*RevocationsResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{52} + return file_api_sam_proto_rawDescGZIP(), []int{64} } func (x *RevocationsResponse) GetRevocationIds() []string { @@ -4524,7 +5526,110 @@ const file_api_sam_proto_rawDesc = "" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\"E\n" + "\x13TokenRevokeResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error\"\xbd\x02\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\xdb\x01\n" + + "\x1bBootstrapTokenCreateRequest\x12\x12\n" + + "\x04role\x18\x01 \x01(\tR\x04role\x12\x19\n" + + "\bowner_id\x18\x02 \x01(\tR\aownerId\x12\x1b\n" + + "\tttl_hours\x18\x03 \x01(\x05R\bttlHours\x12\x1d\n" + + "\n" + + "max_usages\x18\x04 \x01(\x05R\tmaxUsages\x12 \n" + + "\vdescription\x18\x05 \x01(\tR\vdescription\x12/\n" + + "\x13autonomous_recovery\x18\x06 \x01(\bR\x12autonomousRecovery\"\xb0\x01\n" + + "\x1cBootstrapTokenCreateResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05token\x18\x02 \x01(\tR\x05token\x12\x12\n" + + "\x04role\x18\x03 \x01(\tR\x04role\x12\x19\n" + + "\bowner_id\x18\x04 \x01(\tR\aownerId\x12;\n" + + "\vexpire_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "expireTime\"\x9b\x03\n" + + "\x0eBootstrapToken\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04role\x18\x02 \x01(\tR\x04role\x12\x19\n" + + "\bowner_id\x18\x03 \x01(\tR\aownerId\x12\x1d\n" + + "\n" + + "max_usages\x18\x04 \x01(\x05R\tmaxUsages\x12!\n" + + "\fusages_count\x18\x05 \x01(\x05R\vusagesCount\x12 \n" + + "\vdescription\x18\x06 \x01(\tR\vdescription\x12;\n" + + "\vcreate_time\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "createTime\x12;\n" + + "\vexpire_time\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "expireTime\x12;\n" + + "\vrevoke_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "revokeTime\x12/\n" + + "\x13autonomous_recovery\x18\n" + + " \x01(\bR\x12autonomousRecovery\"L\n" + + "\x1aBootstrapTokenListResponse\x12.\n" + + "\x06tokens\x18\x01 \x03(\v2\x16.sam.v1.BootstrapTokenR\x06tokens\"\xa0\x03\n" + + "\x11EnrollmentRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + + "\apeer_id\x18\x02 \x01(\tR\x06peerId\x12\x19\n" + + "\btoken_id\x18\x03 \x01(\tR\atokenId\x120\n" + + "\x06status\x18\x04 \x01(\x0e2\x18.sam.v1.EnrollmentStatusR\x06status\x12=\n" + + "\x06labels\x18\x05 \x03(\v2%.sam.v1.EnrollmentRequest.LabelsEntryR\x06labels\x12;\n" + + "\vcreate_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "createTime\x12=\n" + + "\fresolve_time\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\vresolveTime\x12\x1f\n" + + "\vresolved_by\x18\b \x01(\tR\n" + + "resolvedBy\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"V\n" + + "\x1dEnrollmentRequestListResponse\x125\n" + + "\brequests\x18\x01 \x03(\v2\x19.sam.v1.EnrollmentRequestR\brequests\"\x95\x01\n" + + "\x04User\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06issuer\x18\x02 \x01(\tR\x06issuer\x12\x14\n" + + "\x05email\x18\x03 \x01(\tR\x05email\x12\x12\n" + + "\x04role\x18\x04 \x01(\tR\x04role\x12;\n" + + "\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "createTime\"\xd8\x03\n" + + "\fEnrolledNode\x12\x17\n" + + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x12\n" + + "\x04role\x18\x02 \x01(\tR\x04role\x12'\n" + + "\x0fenrollment_type\x18\x03 \x01(\tR\x0eenrollmentType\x12\x1f\n" + + "\vclaims_json\x18\x04 \x01(\tR\n" + + "claimsJson\x12\x19\n" + + "\bowner_id\x18\x05 \x01(\tR\aownerId\x128\n" + + "\x06labels\x18\x06 \x03(\v2 .sam.v1.EnrolledNode.LabelsEntryR\x06labels\x12;\n" + + "\venroll_time\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "enrollTime\x12;\n" + + "\vexpire_time\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "expireTime\x12\x16\n" + + "\x06banned\x18\t \x01(\bR\x06banned\x12/\n" + + "\x13autonomous_recovery\x18\n" + + " \x01(\bR\x12autonomousRecovery\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8d\x02\n" + + "\vRouterLease\x12\x17\n" + + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x1c\n" + + "\taddresses\x18\x02 \x03(\tR\taddresses\x12F\n" + + "\x11last_renewal_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x0flastRenewalTime\x12;\n" + + "\vexpire_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "expireTime\x12'\n" + + "\x0fconnected_peers\x18\x05 \x03(\tR\x0econnectedPeers\x12\x19\n" + + "\bdht_size\x18\x06 \x01(\x05R\adhtSize\"|\n" + + "\fNodeServices\x12/\n" + + "\bservices\x18\x01 \x03(\v2\x13.sam.v1.ServiceInfoR\bservices\x12;\n" + + "\vreport_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "reportTime\"\x96\x04\n" + + "\x13AdminStatusResponse\x12\"\n" + + "\x05users\x18\x01 \x03(\v2\f.sam.v1.UserR\x05users\x12:\n" + + "\x0eactive_routers\x18\x02 \x03(\v2\x13.sam.v1.RouterLeaseR\ractiveRouters\x12;\n" + + "\x0eenrolled_nodes\x18\x03 \x03(\v2\x14.sam.v1.EnrolledNodeR\renrolledNodes\x12J\n" + + "\x13enrollment_requests\x18\x04 \x03(\v2\x19.sam.v1.EnrollmentRequestR\x12enrollmentRequests\x12A\n" + + "\x10bootstrap_tokens\x18\x05 \x03(\v2\x16.sam.v1.BootstrapTokenR\x0fbootstrapTokens\x12,\n" + + "\x06policy\x18\x06 \x01(\v2\x14.sam.v1.PolicyConfigR\x06policy\x12O\n" + + "\fnode_catalog\x18\a \x03(\v2,.sam.v1.AdminStatusResponse.NodeCatalogEntryR\vnodeCatalog\x1aT\n" + + "\x10NodeCatalogEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + + "\x05value\x18\x02 \x01(\v2\x14.sam.v1.NodeServicesR\x05value:\x028\x01\"\xa0\x02\n" + + "\x12UserStatusResponse\x12 \n" + + "\x04user\x18\x01 \x01(\v2\f.sam.v1.UserR\x04user\x12;\n" + + "\x0eenrolled_nodes\x18\x02 \x03(\v2\x14.sam.v1.EnrolledNodeR\renrolledNodes\x12A\n" + + "\x10bootstrap_tokens\x18\x03 \x03(\v2\x16.sam.v1.BootstrapTokenR\x0fbootstrapTokens\x12:\n" + + "\x0eactive_routers\x18\x04 \x03(\v2\x13.sam.v1.RouterLeaseR\ractiveRouters\x12,\n" + + "\x06policy\x18\x05 \x01(\v2\x14.sam.v1.PolicyConfigR\x06policy\"\xbd\x02\n" + "\x18IdentityEvidenceResponse\x12\x17\n" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x18\n" + "\abiscuit\x18\x02 \x01(\fR\abiscuit\x12J\n" + @@ -4641,7 +5746,7 @@ func file_api_sam_proto_rawDescGZIP() []byte { } var file_api_sam_proto_enumTypes = make([]protoimpl.EnumInfo, 7) -var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 58) +var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 73) var file_api_sam_proto_goTypes = []any{ (EnrollmentStatus)(0), // 0: sam.v1.EnrollmentStatus (ServiceType)(0), // 1: sam.v1.ServiceType @@ -4690,43 +5795,58 @@ var file_api_sam_proto_goTypes = []any{ (*NodeCatalogReport)(nil), // 44: sam.v1.NodeCatalogReport (*TokenRevokeRequest)(nil), // 45: sam.v1.TokenRevokeRequest (*TokenRevokeResponse)(nil), // 46: sam.v1.TokenRevokeResponse - (*IdentityEvidenceResponse)(nil), // 47: sam.v1.IdentityEvidenceResponse - (*PeerEvidenceResponse)(nil), // 48: sam.v1.PeerEvidenceResponse - (*MemberCredential)(nil), // 49: sam.v1.MemberCredential - (*TrustedSigningKey)(nil), // 50: sam.v1.TrustedSigningKey - (*OIDCSession)(nil), // 51: sam.v1.OIDCSession - (*TaskAuthorizationRule)(nil), // 52: sam.v1.TaskAuthorizationRule - (*TaskRule)(nil), // 53: sam.v1.TaskRule - (*TaskOperation)(nil), // 54: sam.v1.TaskOperation - (*TokenExchangeRequest)(nil), // 55: sam.v1.TokenExchangeRequest - (*TokenExchangeResponse)(nil), // 56: sam.v1.TokenExchangeResponse - (*STSTokenRequest)(nil), // 57: sam.v1.STSTokenRequest - (*STSTokenResponse)(nil), // 58: sam.v1.STSTokenResponse - (*RevocationsResponse)(nil), // 59: sam.v1.RevocationsResponse - nil, // 60: sam.v1.EnrollRequest.LabelsEntry - nil, // 61: sam.v1.BootstrapEnrollRequest.LabelsEntry - nil, // 62: sam.v1.CommandBackend.EnvEntry - nil, // 63: sam.v1.ServiceAnnounce.LabelsEntry - nil, // 64: sam.v1.PeerEvidenceResponse.LabelsEntry - (*timestamppb.Timestamp)(nil), // 65: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 66: google.protobuf.Duration + (*BootstrapTokenCreateRequest)(nil), // 47: sam.v1.BootstrapTokenCreateRequest + (*BootstrapTokenCreateResponse)(nil), // 48: sam.v1.BootstrapTokenCreateResponse + (*BootstrapToken)(nil), // 49: sam.v1.BootstrapToken + (*BootstrapTokenListResponse)(nil), // 50: sam.v1.BootstrapTokenListResponse + (*EnrollmentRequest)(nil), // 51: sam.v1.EnrollmentRequest + (*EnrollmentRequestListResponse)(nil), // 52: sam.v1.EnrollmentRequestListResponse + (*User)(nil), // 53: sam.v1.User + (*EnrolledNode)(nil), // 54: sam.v1.EnrolledNode + (*RouterLease)(nil), // 55: sam.v1.RouterLease + (*NodeServices)(nil), // 56: sam.v1.NodeServices + (*AdminStatusResponse)(nil), // 57: sam.v1.AdminStatusResponse + (*UserStatusResponse)(nil), // 58: sam.v1.UserStatusResponse + (*IdentityEvidenceResponse)(nil), // 59: sam.v1.IdentityEvidenceResponse + (*PeerEvidenceResponse)(nil), // 60: sam.v1.PeerEvidenceResponse + (*MemberCredential)(nil), // 61: sam.v1.MemberCredential + (*TrustedSigningKey)(nil), // 62: sam.v1.TrustedSigningKey + (*OIDCSession)(nil), // 63: sam.v1.OIDCSession + (*TaskAuthorizationRule)(nil), // 64: sam.v1.TaskAuthorizationRule + (*TaskRule)(nil), // 65: sam.v1.TaskRule + (*TaskOperation)(nil), // 66: sam.v1.TaskOperation + (*TokenExchangeRequest)(nil), // 67: sam.v1.TokenExchangeRequest + (*TokenExchangeResponse)(nil), // 68: sam.v1.TokenExchangeResponse + (*STSTokenRequest)(nil), // 69: sam.v1.STSTokenRequest + (*STSTokenResponse)(nil), // 70: sam.v1.STSTokenResponse + (*RevocationsResponse)(nil), // 71: sam.v1.RevocationsResponse + nil, // 72: sam.v1.EnrollRequest.LabelsEntry + nil, // 73: sam.v1.BootstrapEnrollRequest.LabelsEntry + nil, // 74: sam.v1.CommandBackend.EnvEntry + nil, // 75: sam.v1.ServiceAnnounce.LabelsEntry + nil, // 76: sam.v1.EnrollmentRequest.LabelsEntry + nil, // 77: sam.v1.EnrolledNode.LabelsEntry + nil, // 78: sam.v1.AdminStatusResponse.NodeCatalogEntry + nil, // 79: sam.v1.PeerEvidenceResponse.LabelsEntry + (*timestamppb.Timestamp)(nil), // 80: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 81: google.protobuf.Duration } var file_api_sam_proto_depIdxs = []int32{ 4, // 0: sam.v1.MeshEvent.type:type_name -> sam.v1.MeshEvent.Type - 65, // 1: sam.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp - 60, // 2: sam.v1.EnrollRequest.labels:type_name -> sam.v1.EnrollRequest.LabelsEntry - 65, // 3: sam.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp - 61, // 4: sam.v1.BootstrapEnrollRequest.labels:type_name -> sam.v1.BootstrapEnrollRequest.LabelsEntry + 80, // 1: sam.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp + 72, // 2: sam.v1.EnrollRequest.labels:type_name -> sam.v1.EnrollRequest.LabelsEntry + 80, // 3: sam.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 73, // 4: sam.v1.BootstrapEnrollRequest.labels:type_name -> sam.v1.BootstrapEnrollRequest.LabelsEntry 0, // 5: sam.v1.BootstrapEnrollResponse.status:type_name -> sam.v1.EnrollmentStatus - 65, // 6: sam.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 80, // 6: sam.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp 1, // 7: sam.v1.ServiceInfo.type:type_name -> sam.v1.ServiceType - 62, // 8: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry + 74, // 8: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry 14, // 9: sam.v1.RegisterServiceRequest.service:type_name -> sam.v1.ServiceInfo 15, // 10: sam.v1.RegisterServiceRequest.command:type_name -> sam.v1.CommandBackend 1, // 11: sam.v1.ServiceAnnounce.type:type_name -> sam.v1.ServiceType - 63, // 12: sam.v1.ServiceAnnounce.labels:type_name -> sam.v1.ServiceAnnounce.LabelsEntry - 65, // 13: sam.v1.ServiceAnnounce.announce_time:type_name -> google.protobuf.Timestamp - 65, // 14: sam.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp + 75, // 12: sam.v1.ServiceAnnounce.labels:type_name -> sam.v1.ServiceAnnounce.LabelsEntry + 80, // 13: sam.v1.ServiceAnnounce.announce_time:type_name -> google.protobuf.Timestamp + 80, // 14: sam.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp 23, // 15: sam.v1.PolicyRole.http:type_name -> sam.v1.HTTPGrant 30, // 16: sam.v1.EgressDestination.broker:type_name -> sam.v1.CredentialBroker 25, // 17: sam.v1.EgressDestination.inspection:type_name -> sam.v1.Inspection @@ -4735,9 +5855,9 @@ var file_api_sam_proto_depIdxs = []int32{ 27, // 20: sam.v1.Inspector.model_armor:type_name -> sam.v1.ModelArmor 28, // 21: sam.v1.Inspector.ext_proc:type_name -> sam.v1.ExtProc 3, // 22: sam.v1.ModelArmor.response:type_name -> sam.v1.ResponseInspection - 66, // 23: sam.v1.ModelArmor.timeout:type_name -> google.protobuf.Duration + 81, // 23: sam.v1.ModelArmor.timeout:type_name -> google.protobuf.Duration 29, // 24: sam.v1.ExtProc.processing_mode:type_name -> sam.v1.ExtProcProcessingMode - 66, // 25: sam.v1.ExtProc.message_timeout:type_name -> google.protobuf.Duration + 81, // 25: sam.v1.ExtProc.message_timeout:type_name -> google.protobuf.Duration 5, // 26: sam.v1.ExtProcProcessingMode.request_header_mode:type_name -> sam.v1.ExtProcProcessingMode.HeaderMode 5, // 27: sam.v1.ExtProcProcessingMode.response_header_mode:type_name -> sam.v1.ExtProcProcessingMode.HeaderMode 6, // 28: sam.v1.ExtProcProcessingMode.request_body_mode:type_name -> sam.v1.ExtProcProcessingMode.BodyMode @@ -4751,29 +5871,60 @@ var file_api_sam_proto_depIdxs = []int32{ 34, // 36: sam.v1.PolicyConfig.bindings:type_name -> sam.v1.PolicyBinding 24, // 37: sam.v1.PolicyConfig.egress:type_name -> sam.v1.EgressDestination 24, // 38: sam.v1.EgressAssignmentsResponse.egress:type_name -> sam.v1.EgressDestination - 65, // 39: sam.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp - 65, // 40: sam.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp + 80, // 39: sam.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp + 80, // 40: sam.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp 14, // 41: sam.v1.NodeCatalogReport.services:type_name -> sam.v1.ServiceInfo - 65, // 42: sam.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp - 65, // 43: sam.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 64, // 44: sam.v1.PeerEvidenceResponse.labels:type_name -> sam.v1.PeerEvidenceResponse.LabelsEntry - 65, // 45: sam.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp - 65, // 46: sam.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 65, // 47: sam.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp - 50, // 48: sam.v1.MemberCredential.trusted_keys:type_name -> sam.v1.TrustedSigningKey - 51, // 49: sam.v1.MemberCredential.oidc_session:type_name -> sam.v1.OIDCSession - 65, // 50: sam.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp - 53, // 51: sam.v1.TaskAuthorizationRule.rules:type_name -> sam.v1.TaskRule - 65, // 52: sam.v1.TaskAuthorizationRule.expire_time:type_name -> google.protobuf.Timestamp - 54, // 53: sam.v1.TaskRule.operation:type_name -> sam.v1.TaskOperation - 52, // 54: sam.v1.TokenExchangeRequest.task_rule:type_name -> sam.v1.TaskAuthorizationRule - 65, // 55: sam.v1.TokenExchangeResponse.expire_time:type_name -> google.protobuf.Timestamp - 65, // 56: sam.v1.STSTokenResponse.expire_time:type_name -> google.protobuf.Timestamp - 57, // [57:57] is the sub-list for method output_type - 57, // [57:57] is the sub-list for method input_type - 57, // [57:57] is the sub-list for extension type_name - 57, // [57:57] is the sub-list for extension extendee - 0, // [0:57] is the sub-list for field type_name + 80, // 42: sam.v1.BootstrapTokenCreateResponse.expire_time:type_name -> google.protobuf.Timestamp + 80, // 43: sam.v1.BootstrapToken.create_time:type_name -> google.protobuf.Timestamp + 80, // 44: sam.v1.BootstrapToken.expire_time:type_name -> google.protobuf.Timestamp + 80, // 45: sam.v1.BootstrapToken.revoke_time:type_name -> google.protobuf.Timestamp + 49, // 46: sam.v1.BootstrapTokenListResponse.tokens:type_name -> sam.v1.BootstrapToken + 0, // 47: sam.v1.EnrollmentRequest.status:type_name -> sam.v1.EnrollmentStatus + 76, // 48: sam.v1.EnrollmentRequest.labels:type_name -> sam.v1.EnrollmentRequest.LabelsEntry + 80, // 49: sam.v1.EnrollmentRequest.create_time:type_name -> google.protobuf.Timestamp + 80, // 50: sam.v1.EnrollmentRequest.resolve_time:type_name -> google.protobuf.Timestamp + 51, // 51: sam.v1.EnrollmentRequestListResponse.requests:type_name -> sam.v1.EnrollmentRequest + 80, // 52: sam.v1.User.create_time:type_name -> google.protobuf.Timestamp + 77, // 53: sam.v1.EnrolledNode.labels:type_name -> sam.v1.EnrolledNode.LabelsEntry + 80, // 54: sam.v1.EnrolledNode.enroll_time:type_name -> google.protobuf.Timestamp + 80, // 55: sam.v1.EnrolledNode.expire_time:type_name -> google.protobuf.Timestamp + 80, // 56: sam.v1.RouterLease.last_renewal_time:type_name -> google.protobuf.Timestamp + 80, // 57: sam.v1.RouterLease.expire_time:type_name -> google.protobuf.Timestamp + 14, // 58: sam.v1.NodeServices.services:type_name -> sam.v1.ServiceInfo + 80, // 59: sam.v1.NodeServices.report_time:type_name -> google.protobuf.Timestamp + 53, // 60: sam.v1.AdminStatusResponse.users:type_name -> sam.v1.User + 55, // 61: sam.v1.AdminStatusResponse.active_routers:type_name -> sam.v1.RouterLease + 54, // 62: sam.v1.AdminStatusResponse.enrolled_nodes:type_name -> sam.v1.EnrolledNode + 51, // 63: sam.v1.AdminStatusResponse.enrollment_requests:type_name -> sam.v1.EnrollmentRequest + 49, // 64: sam.v1.AdminStatusResponse.bootstrap_tokens:type_name -> sam.v1.BootstrapToken + 35, // 65: sam.v1.AdminStatusResponse.policy:type_name -> sam.v1.PolicyConfig + 78, // 66: sam.v1.AdminStatusResponse.node_catalog:type_name -> sam.v1.AdminStatusResponse.NodeCatalogEntry + 53, // 67: sam.v1.UserStatusResponse.user:type_name -> sam.v1.User + 54, // 68: sam.v1.UserStatusResponse.enrolled_nodes:type_name -> sam.v1.EnrolledNode + 49, // 69: sam.v1.UserStatusResponse.bootstrap_tokens:type_name -> sam.v1.BootstrapToken + 55, // 70: sam.v1.UserStatusResponse.active_routers:type_name -> sam.v1.RouterLease + 35, // 71: sam.v1.UserStatusResponse.policy:type_name -> sam.v1.PolicyConfig + 80, // 72: sam.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp + 80, // 73: sam.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 79, // 74: sam.v1.PeerEvidenceResponse.labels:type_name -> sam.v1.PeerEvidenceResponse.LabelsEntry + 80, // 75: sam.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp + 80, // 76: sam.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 80, // 77: sam.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp + 62, // 78: sam.v1.MemberCredential.trusted_keys:type_name -> sam.v1.TrustedSigningKey + 63, // 79: sam.v1.MemberCredential.oidc_session:type_name -> sam.v1.OIDCSession + 80, // 80: sam.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp + 65, // 81: sam.v1.TaskAuthorizationRule.rules:type_name -> sam.v1.TaskRule + 80, // 82: sam.v1.TaskAuthorizationRule.expire_time:type_name -> google.protobuf.Timestamp + 66, // 83: sam.v1.TaskRule.operation:type_name -> sam.v1.TaskOperation + 64, // 84: sam.v1.TokenExchangeRequest.task_rule:type_name -> sam.v1.TaskAuthorizationRule + 80, // 85: sam.v1.TokenExchangeResponse.expire_time:type_name -> google.protobuf.Timestamp + 80, // 86: sam.v1.STSTokenResponse.expire_time:type_name -> google.protobuf.Timestamp + 56, // 87: sam.v1.AdminStatusResponse.NodeCatalogEntry.value:type_name -> sam.v1.NodeServices + 88, // [88:88] is the sub-list for method output_type + 88, // [88:88] is the sub-list for method input_type + 88, // [88:88] is the sub-list for extension type_name + 88, // [88:88] is the sub-list for extension extendee + 0, // [0:88] is the sub-list for field type_name } func init() { file_api_sam_proto_init() } @@ -4801,7 +5952,7 @@ func file_api_sam_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_sam_proto_rawDesc), len(file_api_sam_proto_rawDesc)), NumEnums: 7, - NumMessages: 58, + NumMessages: 73, NumExtensions: 0, NumServices: 0, }, diff --git a/api/sam.proto b/api/sam.proto index db1ff822..cc9d4ed1 100644 --- a/api/sam.proto +++ b/api/sam.proto @@ -504,6 +504,160 @@ message TokenRevokeResponse { string error = 2; } +// ============================================================================ +// Operator plane +// ============================================================================ +// +// What the console and admin CLIs exchange with the control plane on +// /admin/* and /user/*: protojson of these messages with proto field names, +// unknown fields rejected. Credentials and key material never appear here: +// an enrollment request's public key and minted biscuit, an enrolled node's +// biscuit and a token's hash stay in the store. + +// BootstrapTokenCreateRequest is the body of POST /admin/bootstrap-tokens +// (admin bearer) and POST /user/bootstrap-tokens (mesh user). +message BootstrapTokenCreateRequest { + // Role the token enrolls into, e.g. "sam:role:node". Required on the admin + // endpoint; the user endpoint defaults it to "sam:role:node". + string role = 1; + // User the token is issued on behalf of. Honored by the user endpoint + // only, and only for admins; defaults to the caller. + string owner_id = 2; + // How long the token stays valid; unset or non-positive means 24. + int32 ttl_hours = 3; + // How many enrollments the token admits; unset or non-positive means 1. + int32 max_usages = 4; + // Free-form operator note stored with the token. + string description = 5; + // Copied onto every node the token enrolls: such a node may still refresh + // its credential after the control plane's signing key rotated past its + // grace period, on proof of possession of its own key alone. Admin-only, + // because a node that can always recover holds a credential that never + // expires. + bool autonomous_recovery = 6; +} + +// BootstrapTokenCreateResponse is returned (201) when a token is minted. +// token is the plaintext and is shown exactly once; the control plane keeps +// only its hash, which is also the id. +message BootstrapTokenCreateResponse { + string id = 1; + string token = 2; + string role = 3; + string owner_id = 4; + google.protobuf.Timestamp expire_time = 5; +} + +// BootstrapToken is a minted token as operators list it. +message BootstrapToken { + string id = 1; + string role = 2; + string owner_id = 3; + int32 max_usages = 4; + int32 usages_count = 5; + string description = 6; + google.protobuf.Timestamp create_time = 7; + google.protobuf.Timestamp expire_time = 8; + // Set when an operator revoked the token, which is distinct from expiry + // or exhausted usages. Unset means never revoked. + google.protobuf.Timestamp revoke_time = 9; + bool autonomous_recovery = 10; +} + +message BootstrapTokenListResponse { + repeated BootstrapToken tokens = 1; +} + +// EnrollmentRequest is a bootstrap enrollment awaiting or past an operator +// decision (see BootstrapEnrollRequest). +message EnrollmentRequest { + string id = 1; + string peer_id = 2; + // The bootstrap token the request was made with. + string token_id = 3; + EnrollmentStatus status = 4; + // Labels the node declared; approval attests them into its biscuit. + map labels = 5; + google.protobuf.Timestamp create_time = 6; + // Unset while the request is pending. + google.protobuf.Timestamp resolve_time = 7; + string resolved_by = 8; +} + +message EnrollmentRequestListResponse { + repeated EnrollmentRequest requests = 1; +} + +// User is a human identity known to the mesh. +message User { + // The identity provider's subject. + string id = 1; + string issuer = 2; + string email = 3; + // "admin" or "user". + string role = 4; + google.protobuf.Timestamp create_time = 5; +} + +// EnrolledNode is a member's enrollment record without its credential. +message EnrolledNode { + string peer_id = 1; + string role = 2; + // How the node enrolled, e.g. "oidc" or "bootstrap". + string enrollment_type = 3; + // The identity provider's claims as stored at enrollment. Admin-only. + string claims_json = 4; + string owner_id = 5; + map labels = 6; + google.protobuf.Timestamp enroll_time = 7; + // When the enrollment session ends; unset means it does not expire. + google.protobuf.Timestamp expire_time = 8; + bool banned = 9; + bool autonomous_recovery = 10; +} + +// RouterLease is a router's current registration with the control plane. +message RouterLease { + string peer_id = 1; + // Multiaddrs, `/p2p/` suffixed. + repeated string addresses = 2; + google.protobuf.Timestamp last_renewal_time = 3; + google.protobuf.Timestamp expire_time = 4; + repeated string connected_peers = 5; + int32 dht_size = 6; +} + +// NodeServices is the services one node last reported (see +// NodeCatalogReport). +message NodeServices { + repeated ServiceInfo services = 1; + google.protobuf.Timestamp report_time = 2; +} + +// AdminStatusResponse answers GET /admin/status: everything the console +// shows an administrator. +message AdminStatusResponse { + repeated User users = 1; + repeated RouterLease active_routers = 2; + repeated EnrolledNode enrolled_nodes = 3; + repeated EnrollmentRequest enrollment_requests = 4; + repeated BootstrapToken bootstrap_tokens = 5; + PolicyConfig policy = 6; + // Keyed by the reporting node's peer ID; admitted nodes only. + map node_catalog = 7; +} + +// UserStatusResponse answers GET /user/status: the caller and what it owns. +// The router fleet and the mesh policy describe the whole mesh and are set +// for an administrator only. +message UserStatusResponse { + User user = 1; + repeated EnrolledNode enrolled_nodes = 2; + repeated BootstrapToken bootstrap_tokens = 3; + repeated RouterLease active_routers = 4; + PolicyConfig policy = 5; +} + // ============================================================================ // Identity Evidence API // ============================================================================ diff --git a/charts/sam-mesh/Chart.yaml b/charts/sam-mesh/Chart.yaml index 59fbb336..c7b90481 100644 --- a/charts/sam-mesh/Chart.yaml +++ b/charts/sam-mesh/Chart.yaml @@ -3,4 +3,4 @@ name: sam-mesh description: A Helm chart for deploying the SAM control plane, router and console type: application version: 0.1.0 -appVersion: "1.0.0" +appVersion: "0.1.0" diff --git a/cmd/mcp-client/main.go b/cmd/mcp-client/main.go index 39d89b24..182d5813 100644 --- a/cmd/mcp-client/main.go +++ b/cmd/mcp-client/main.go @@ -32,6 +32,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/google/sam/api" + "github.com/google/sam/internal/version" ) func main() { @@ -42,7 +43,12 @@ func main() { listTools := flag.Bool("list", false, "List available tools and exit") streamOpt := flag.Bool("stream", false, "Enable streaming mode for service discovery HTTP API") tokenOpt := flag.String("token", "", "Authorization Bearer token for protected sidecar endpoints") + showVersion := flag.Bool("version", false, "Print the version and exit") flag.Parse() + if *showVersion { + fmt.Println(version.String()) + return + } if *serverURL == "" { log.Fatal("Must specify -url") @@ -143,7 +149,7 @@ func main() { // Create MCP client client := mcp.NewClient(&mcp.Implementation{ Name: "mcp-test-client", - Version: "0.1.0", + Version: version.String(), }, nil) // Connect to server using the URL diff --git a/cmd/sam-console/main.go b/cmd/sam-console/main.go index b96056a6..e431f2c0 100644 --- a/cmd/sam-console/main.go +++ b/cmd/sam-console/main.go @@ -17,6 +17,7 @@ package main import ( "context" "flag" + "fmt" "log" "net/http" "os" @@ -26,6 +27,7 @@ import ( "github.com/google/sam/internal/console" "github.com/google/sam/internal/secrets" + "github.com/google/sam/internal/version" ) func main() { @@ -36,8 +38,13 @@ func main() { staticDir = flag.String("static-dir", "", "Directory containing static frontend files (default: assets embedded in the binary)") basePath = flag.String("base-path", "", "Base path prefix for the console (e.g. /console)") externalURL = flag.String("external-url", "", "Origin browsers reach this console on, e.g. https://console.example. Sets the OIDC redirect_uri and cookie Secure flag instead of trusting the Host and X-Forwarded-Proto headers") + showVersion = flag.Bool("version", false, "Print the version and exit") ) flag.Parse() + if *showVersion { + fmt.Println(version.String()) + return + } adminToken, err := secrets.FromPathOrEnv("admin-token", *adminTokenPath, "SAM_ADMIN_TOKEN") if err != nil { @@ -46,6 +53,9 @@ func main() { if adminToken == "" { log.Fatal("Admin token is required (via --admin-token-path or env SAM_ADMIN_TOKEN)") } + if *externalURL == "" { + log.Print("WARNING: --external-url is not set; the OIDC redirect_uri and the session cookie's Secure flag follow the Host and X-Forwarded-Proto request headers, which a client controls. Set --external-url when the console is reachable beyond localhost.") + } srv, err := console.NewServer(console.Config{ ControlPlaneURL: *controlPlaneURL, diff --git a/cmd/sam-control-plane/main.go b/cmd/sam-control-plane/main.go index aa3e1208..0d7779ef 100644 --- a/cmd/sam-control-plane/main.go +++ b/cmd/sam-control-plane/main.go @@ -27,6 +27,7 @@ import ( "github.com/google/sam/internal/controlplane" "github.com/google/sam/internal/secrets" "github.com/google/sam/internal/storage" + "github.com/google/sam/internal/version" golog "github.com/ipfs/go-log/v2" "github.com/libp2p/go-libp2p/core/peer" "github.com/spf13/cobra" @@ -60,8 +61,9 @@ var logger = golog.Logger("sam-control-plane-cli") func main() { rootCmd := &cobra.Command{ - Use: "sam-control-plane", - Short: "Sovereign Agent Mesh - Control Plane", + Use: "sam-control-plane", + Short: "Sovereign Agent Mesh - Control Plane", + Version: version.String(), // Resolve the DB DSN (may embed a password) before any subcommand runs. PersistentPreRunE: func(cmd *cobra.Command, args []string) error { resolved, err := secrets.FromPathOrEnv("db-dsn", dbDSNPath, "SAM_DB_DSN") @@ -89,6 +91,9 @@ func main() { if oidcIssuer == "" && workloadIssuer == "" { logger.Fatalf("OIDC issuer is required (use --issuer or --workload-issuer flag)") } + if insecureSkipTLSVerify { + logger.Warnf("--insecure-skip-tls-verify is set: identity provider TLS certificates are not verified, so anyone on the path to the provider can forge the identities this control plane admits. For development only.") + } adminToken, err := secrets.FromPathOrEnv("admin-token", adminTokenPath, "SAM_ADMIN_TOKEN") if err != nil { diff --git a/cmd/sam-one/admin.go b/cmd/sam-one/admin.go index 87714b05..d859d22b 100644 --- a/cmd/sam-one/admin.go +++ b/cmd/sam-one/admin.go @@ -16,7 +16,6 @@ package main import ( "bytes" - "encoding/json" "fmt" "io" "net/http" @@ -27,8 +26,8 @@ import ( "github.com/google/sam/api" "github.com/google/sam/internal/standalone" - "github.com/google/sam/internal/storage" "github.com/spf13/cobra" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -85,11 +84,11 @@ func (c *adminClient) do(method, path, contentType string, body []byte) ([]byte, // control plane's signing key rotated past its grace period, which is what // a device that spends days offline needs and what a stolen device should // not get. -func (c *adminClient) createToken(role string, ttlHours, maxUsages int, description string, autonomousRecovery bool) (*api.BootstrapTokenResponse, error) { - payload, err := json.Marshal(api.BootstrapTokenRequest{ +func (c *adminClient) createToken(role string, ttlHours, maxUsages int, description string, autonomousRecovery bool) (*api.BootstrapTokenCreateResponse, error) { + payload, err := protojson.Marshal(&api.BootstrapTokenCreateRequest{ Role: role, - TTLHours: ttlHours, - MaxUsages: maxUsages, + TtlHours: int32(ttlHours), + MaxUsages: int32(maxUsages), Description: description, AutonomousRecovery: autonomousRecovery, }) @@ -100,23 +99,23 @@ func (c *adminClient) createToken(role string, ttlHours, maxUsages int, descript if err != nil { return nil, err } - var created api.BootstrapTokenResponse - if err := json.Unmarshal(body, &created); err != nil { + created := &api.BootstrapTokenCreateResponse{} + if err := protojson.Unmarshal(body, created); err != nil { return nil, fmt.Errorf("failed to decode response %q: %w", body, err) } - return &created, nil + return created, nil } -func (c *adminClient) listTokens() ([]storage.BootstrapToken, error) { +func (c *adminClient) listTokens() ([]*api.BootstrapToken, error) { body, err := c.do(http.MethodGet, "/admin/bootstrap-tokens", "", nil) if err != nil { return nil, err } - var list []storage.BootstrapToken - if err := json.Unmarshal(body, &list); err != nil { + list := &api.BootstrapTokenListResponse{} + if err := protojson.Unmarshal(body, list); err != nil { return nil, fmt.Errorf("failed to decode response %q: %w", body, err) } - return list, nil + return list.GetTokens(), nil } func (c *adminClient) banPeer(peerID string) error { @@ -149,8 +148,8 @@ func (c *adminClient) resolveTokenID(idOrPrefix string) (string, error) { } var matches []string for _, tok := range list { - if strings.HasPrefix(tok.ID, idOrPrefix) { - matches = append(matches, tok.ID) + if strings.HasPrefix(tok.GetId(), idOrPrefix) { + matches = append(matches, tok.GetId()) } } switch len(matches) { @@ -211,9 +210,9 @@ func newAdminSubcommands() []*cobra.Command { if err != nil { return err } - cmd.Printf("Token: %s\n", created.Token) - cmd.Printf("Role: %s\n", created.Role) - cmd.Printf("Expires: %s\n", created.ExpiresAt) + cmd.Printf("Token: %s\n", created.GetToken()) + cmd.Printf("Role: %s\n", created.GetRole()) + cmd.Printf("Expires: %s\n", created.GetExpireTime().AsTime().Format(time.RFC3339)) cmd.PrintErrln("The plain token is shown only once; store it now.") return nil }, @@ -240,13 +239,13 @@ func newAdminSubcommands() []*cobra.Command { tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0) _, _ = fmt.Fprintln(tw, "ID\tROLE\tUSAGES\tSTATUS\tEXPIRES\tDESCRIPTION") for _, tok := range list { - id := tok.ID + id := tok.GetId() if len(id) > 12 { id = id[:12] } _, _ = fmt.Fprintf(tw, "%s\t%s\t%d/%d\t%s\t%s\t%s\n", - id, tok.Role, tok.UsagesCount, tok.MaxUsages, tokenStatus(&tok, now), - tok.ExpiresAt.Format(time.RFC3339), tok.Description) + id, tok.GetRole(), tok.GetUsagesCount(), tok.GetMaxUsages(), tokenStatus(tok, now), + tok.GetExpireTime().AsTime().Format(time.RFC3339), tok.GetDescription()) } return tw.Flush() }, @@ -303,13 +302,13 @@ func newAdminSubcommands() []*cobra.Command { // tokenStatus mirrors the control plane's usability check (/enroll and // ConsumeBootstrapTokenUsage) for display. -func tokenStatus(tok *storage.BootstrapToken, now time.Time) string { +func tokenStatus(tok *api.BootstrapToken, now time.Time) string { switch { - case tok.IsRevoked(): + case tok.GetRevokeTime() != nil: return "revoked" - case !tok.ExpiresAt.IsZero() && now.After(tok.ExpiresAt): + case tok.GetExpireTime() != nil && now.After(tok.GetExpireTime().AsTime()): return "expired" - case tok.UsagesCount >= tok.MaxUsages: + case tok.GetUsagesCount() >= tok.GetMaxUsages(): return "exhausted" default: return "active" diff --git a/cmd/sam-one/admin_test.go b/cmd/sam-one/admin_test.go index 09229a51..d4a3b9d1 100644 --- a/cmd/sam-one/admin_test.go +++ b/cmd/sam-one/admin_test.go @@ -15,7 +15,6 @@ package main import ( - "encoding/json" "fmt" "io" "net/http" @@ -27,8 +26,9 @@ import ( "time" "github.com/google/sam/api" - "github.com/google/sam/internal/storage" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) func newFakeAdminAPI(t *testing.T) *httptest.Server { @@ -43,31 +43,34 @@ func newFakeAdminAPI(t *testing.T) *httptest.Server { case http.MethodPost: // Decode into the shared wire type, as the control plane does, so a // client that drifts from it fails here. - var req api.BootstrapTokenRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + body, _ := io.ReadAll(r.Body) + req := &api.BootstrapTokenCreateRequest{} + if err := protojson.Unmarshal(body, req); err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } - if !req.AutonomousRecovery || req.MaxUsages != 1 || req.TTLHours != 24 || req.Description != "note" { + if !req.GetAutonomousRecovery() || req.GetMaxUsages() != 1 || req.GetTtlHours() != 24 || req.GetDescription() != "note" { http.Error(w, fmt.Sprintf("unexpected request %+v", req), http.StatusBadRequest) return } w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(api.BootstrapTokenResponse{ - ID: "abcdef123456", - Token: "sam-bt-fresh", - Role: req.Role, - ExpiresAt: "2026-12-31T00:00:00Z", + out, _ := protojson.MarshalOptions{UseProtoNames: true}.Marshal(&api.BootstrapTokenCreateResponse{ + Id: "abcdef123456", + Token: "sam-bt-fresh", + Role: req.GetRole(), + ExpireTime: timestamppb.New(time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)), }) + _, _ = w.Write(out) case http.MethodGet: - _ = json.NewEncoder(w).Encode([]storage.BootstrapToken{{ - ID: "abcdef123456", + out, _ := protojson.MarshalOptions{UseProtoNames: true}.Marshal(&api.BootstrapTokenListResponse{Tokens: []*api.BootstrapToken{{ + Id: "abcdef123456", Role: api.RoleNode, MaxUsages: 3, UsagesCount: 1, Description: "seeded", - ExpiresAt: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC), - }}) + ExpireTime: timestamppb.New(time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)), + }}}) + _, _ = w.Write(out) default: http.Error(w, "method", http.StatusMethodNotAllowed) } @@ -109,7 +112,7 @@ func TestAdminClient(t *testing.T) { if err != nil { t.Fatalf("createToken failed: %v", err) } - if created.Token != "sam-bt-fresh" || created.Role != api.RoleNode { + if created.GetToken() != "sam-bt-fresh" || created.GetRole() != api.RoleNode { t.Errorf("unexpected created token: %+v", created) } @@ -117,7 +120,7 @@ func TestAdminClient(t *testing.T) { if err != nil { t.Fatalf("listTokens failed: %v", err) } - if len(list) != 1 || list[0].Description != "seeded" || list[0].UsagesCount != 1 { + if len(list) != 1 || list[0].GetDescription() != "seeded" || list[0].GetUsagesCount() != 1 { t.Errorf("unexpected token list: %+v", list) } diff --git a/cmd/sam-one/main.go b/cmd/sam-one/main.go index af489c90..1c9e8775 100644 --- a/cmd/sam-one/main.go +++ b/cmd/sam-one/main.go @@ -31,6 +31,7 @@ import ( "github.com/google/sam/api" "github.com/google/sam/internal/standalone" "github.com/google/sam/internal/tunnel" + "github.com/google/sam/internal/version" golog "github.com/ipfs/go-log/v2" "github.com/spf13/cobra" ) @@ -68,8 +69,9 @@ func main() { ) rootCmd := &cobra.Command{ - Use: "sam-one", - Short: "Sovereign Agent Mesh - all-in-one standalone server", + Use: "sam-one", + Short: "Sovereign Agent Mesh - all-in-one standalone server", + Version: version.String(), Run: func(cmd *cobra.Command, args []string) { if os.Getenv("LOG_FORMAT") == "json" { _ = os.Setenv("GOLOG_LOG_FMT", "json") diff --git a/cmd/sam-router/main.go b/cmd/sam-router/main.go index 168ea3a0..25863e92 100644 --- a/cmd/sam-router/main.go +++ b/cmd/sam-router/main.go @@ -22,6 +22,7 @@ import ( "time" "github.com/google/sam/internal/router" + "github.com/google/sam/internal/version" golog "github.com/ipfs/go-log/v2" "github.com/spf13/cobra" ) @@ -54,8 +55,9 @@ var logger = golog.Logger("sam-router-cli") func main() { rootCmd := &cobra.Command{ - Use: "sam-router", - Short: "Sovereign Agent Mesh - libp2p Router Node", + Use: "sam-router", + Short: "Sovereign Agent Mesh - libp2p Router Node", + Version: version.String(), Run: func(cmd *cobra.Command, args []string) { // Initialize logging if os.Getenv("LOG_FORMAT") == "json" { diff --git a/install.sh b/install.sh index 82aa3b96..9504c395 100755 --- a/install.sh +++ b/install.sh @@ -78,7 +78,7 @@ tar -xzf "${TAR_NAME}" echo "Installing to ${INSTALL_DIR} (may require sudo)..." INSTALLED_BINS=() -for b in sam-one sam-node sam-control-plane sam-router mcp-client sam-box sam-console nano-init; do +for b in sam-one sam-node sam-control-plane sam-router mcp-client sam-console; do if [ -f "$b" ]; then INSTALLED_BINS+=("$b") fi diff --git a/internal/console/public/app.js b/internal/console/public/app.js index 093569f0..84a1799d 100644 --- a/internal/console/public/app.js +++ b/internal/console/public/app.js @@ -221,7 +221,7 @@ async function loadData() { // A revoked node is banned, not deleted: the record survives for unban // and audit, but it is out of the mesh, so no view may list it. - const enrolledNodes = (data.enrolled_nodes || []).filter(node => !node.Banned); + const enrolledNodes = (data.enrolled_nodes || []).filter(node => !node.banned); // Update Stats const usersCount = (data.users && data.users.length) || 0; @@ -234,7 +234,7 @@ async function loadData() { document.getElementById('stat-routers').innerText = routersCount; // Count pending - const pendingCount = (data.enrollment_requests || []).filter(r => r.Status === 0 || r.Status === 'ENROLLMENT_STATUS_PENDING').length; + const pendingCount = (data.enrollment_requests || []).filter(r => r.status === 'ENROLLMENT_STATUS_PENDING').length; document.getElementById('stat-pending').innerText = pendingCount; // Render Tables & Grid @@ -254,8 +254,8 @@ async function loadData() { renderBootstrapTokensTable(data.bootstrap_tokens || []); const policyArea = document.getElementById('policy-yaml'); - if (policyArea && data.policy_json !== undefined && !policyIsDirty()) { - policyArea.value = renderPolicyYAML(data.policy_json); + if (policyArea && role === 'admin' && !policyIsDirty()) { + policyArea.value = renderPolicyYAML(data.policy || {}); policyBaseline = policyArea.value; validatePolicyEditor(); } @@ -329,10 +329,10 @@ function renderUsersTable(users) { tbody.innerHTML = users.map(user => ` - ${escapeHTML(user.ID)} - ${escapeHTML(user.Role)} - ${escapeHTML(user.Email)} - ${new Date(user.CreatedAt).toLocaleString()} + ${escapeHTML(user.id)} + ${escapeHTML(user.role)} + ${escapeHTML(user.email)} + ${user.create_time ? new Date(user.create_time).toLocaleString() : '-'} `).join(''); } @@ -365,7 +365,7 @@ function peerCell(peerID, labels) { function buildLabelsByPeer(nodes) { const byPeer = {}; for (const node of nodes || []) { - byPeer[node.PeerID] = node.Labels || {}; + byPeer[node.peer_id] = node.labels || {}; } return byPeer; } @@ -378,25 +378,25 @@ function renderNodesTable(nodes, role) { } tbody.innerHTML = nodes.map(node => { - const recovery = !!node.AutonomousRecovery; + const recovery = !!node.autonomous_recovery; const recoveryBadge = recovery ? `Autonomous` : `Manual`; // The flag decides whether a lost device can rejoin on its own key, so // only admins get the toggle; owners just see the state. const toggle = role === 'admin' - ? `` + ? `` : ''; return ` - ${peerCell(node.PeerID, node.Labels)} - ${escapeHTML(node.Role)} - ${escapeHTML(node.OwnerID)} + ${peerCell(node.peer_id, node.labels)} + ${escapeHTML(node.role)} + ${escapeHTML(node.owner_id || '')} ${recoveryBadge}
${toggle} - +
@@ -413,9 +413,13 @@ window.setAutonomousRecovery = function(peerID, enabled) { } }; -// node_catalog is {peerID: {services: [{name, type, description}], reported_at}}, +// node_catalog is {peerID: {services: [{name, type, description}], report_time}}, // already restricted server-side to nodes that are still admitted; type is -// the short name ("mcp", "inference", "a2a") rendered by the control plane. +// the ServiceType enum name ("SERVICE_TYPE_MCP"), shown as its short form. +function serviceTypeLabel(type) { + return String(type || '').replace(/^SERVICE_TYPE_/, '').toLowerCase() || 'unknown'; +} + function renderServicesTable(nodeCatalog, labelsByPeer) { const tbody = document.getElementById('table-services'); const peerIDs = Object.keys(nodeCatalog || {}); @@ -425,7 +429,7 @@ function renderServicesTable(nodeCatalog, labelsByPeer) { const services = entry.services || []; for (const svc of services) { if (svc) { - rows.push({ peerID, reportedAt: entry.reported_at, svc }); + rows.push({ peerID, reportedAt: entry.report_time, svc }); } } } @@ -438,7 +442,7 @@ function renderServicesTable(nodeCatalog, labelsByPeer) { tbody.innerHTML = rows.map(({ peerID, reportedAt, svc }) => ` ${escapeHTML(svc.name || '')} - ${escapeHTML(svc.type || 'unknown')} + ${escapeHTML(serviceTypeLabel(svc.type))} ${escapeHTML(svc.description || '')} ${peerCell(peerID, (labelsByPeer || {})[peerID])} ${reportedAt ? escapeHTML(new Date(reportedAt).toLocaleString()) : '-'} @@ -447,11 +451,11 @@ function renderServicesTable(nodeCatalog, labelsByPeer) { } function getStatusBadge(status) { - if (status === 0 || status === 'ENROLLMENT_STATUS_PENDING') { + if (status === 'ENROLLMENT_STATUS_PENDING') { return `Pending`; - } else if (status === 1 || status === 'ENROLLMENT_STATUS_APPROVED') { + } else if (status === 'ENROLLMENT_STATUS_APPROVED') { return `Approved`; - } else if (status === 2 || status === 'ENROLLMENT_STATUS_REJECTED') { + } else if (status === 'ENROLLMENT_STATUS_REJECTED') { return `Rejected`; } return `Unknown`; @@ -465,21 +469,21 @@ function renderEnrollmentsTable(reqs) { } tbody.innerHTML = reqs.map(req => { - const isPending = req.Status === 0 || req.Status === 'ENROLLMENT_STATUS_PENDING'; + const isPending = req.status === 'ENROLLMENT_STATUS_PENDING'; let actions = ''; if (isPending) { actions = `
- - + +
`; } return ` - ${escapeHTML(req.ID)} - ${getStatusBadge(req.Status)} - ${escapeHTML(req.CreatedAt)} + ${escapeHTML(req.id)} + ${getStatusBadge(req.status)} + ${escapeHTML(req.create_time || '')} ${actions} `; @@ -495,9 +499,9 @@ function renderRoutersTable(routers) { tbody.innerHTML = routers.map(router => ` - ${escapeHTML(router.PeerID)} - ${router.Addresses ? router.Addresses.map(addr => escapeHTML(addr)).join('
') : '-'} - ${escapeHTML(router.ExpiresAt)} + ${escapeHTML(router.peer_id)} + ${router.addresses ? router.addresses.map(addr => escapeHTML(addr)).join('
') : '-'} + ${escapeHTML(router.expire_time || '')} `).join(''); } @@ -630,15 +634,15 @@ function policyIsDirty() { // The control plane sends the policy as protojson. Showing it as YAML keeps the // document readable without either side hand-maintaining a second field list. -function renderPolicyYAML(policyJSON) { - if (!policyJSON) { +function renderPolicyYAML(policy) { + if (!policy) { return ''; } try { - return jsyaml.dump(JSON.parse(policyJSON), { indent: 2, lineWidth: -1, noRefs: true }); + return jsyaml.dump(policy, { indent: 2, lineWidth: -1, noRefs: true }); } catch (err) { // Better to show the operator the raw document than an empty editor. - return policyJSON; + return JSON.stringify(policy, null, 2); } } @@ -692,13 +696,13 @@ function renderRouterTopography(routers) { } topoList.innerHTML = routers.map(r => { - const conns = r.ConnectedPeers || []; - const dhtSize = r.DHTSize || 0; - const peerID = String(r.PeerID || ''); + const conns = r.connected_peers || []; + const dhtSize = r.dht_size || 0; + const peerID = String(r.peer_id || ''); let remaining = 0; - if (r.ExpiresAt) { - remaining = Math.max(0, Math.floor((new Date(r.ExpiresAt) - new Date()) / 1000)); + if (r.expire_time) { + remaining = Math.max(0, Math.floor((new Date(r.expire_time) - new Date()) / 1000)); } const leaseClass = remaining === 0 ? 'badge-rejected' : 'badge-approved'; const leaseLabel = remaining === 0 ? 'Lease expired' : `Lease: ${formatDuration(remaining)}`; @@ -738,21 +742,21 @@ function renderBootstrapTokensTable(tokens) { } tbody.innerHTML = tokens.map(token => { - const expiresAt = token.ExpiresAt && !token.ExpiresAt.startsWith('0001') ? new Date(token.ExpiresAt).toLocaleString() : 'Never'; - const revoked = !!token.RevokedAt; + const expiresAt = token.expire_time ? new Date(token.expire_time).toLocaleString() : 'Never'; + const revoked = !!token.revoke_time; const status = revoked ? `Revoked` : `Active`; - const recovery = token.AutonomousRecovery ? 'Autonomous' : 'Manual'; + const recovery = token.autonomous_recovery ? 'Autonomous' : 'Manual'; const action = revoked ? '-' - : ``; + : ``; return ` - ${escapeHTML(String(token.ID || '').substring(0, 8))}... - ${escapeHTML(token.Role)} - ${escapeHTML(token.OwnerID || '-')} - ${escapeHTML(String(token.UsagesCount))} / ${escapeHTML(String(token.MaxUsages))} + ${escapeHTML(String(token.id || '').substring(0, 8))}... + ${escapeHTML(token.role)} + ${escapeHTML(token.owner_id || '-')} + ${escapeHTML(String(token.usages_count || 0))} / ${escapeHTML(String(token.max_usages || 0))} ${escapeHTML(expiresAt)} ${recovery} ${status} diff --git a/internal/controlplane/approval_labels_test.go b/internal/controlplane/approval_labels_test.go index 0c18a6ff..37f7ee65 100644 --- a/internal/controlplane/approval_labels_test.go +++ b/internal/controlplane/approval_labels_test.go @@ -24,9 +24,9 @@ import ( "time" "github.com/google/sam/api" - "github.com/google/sam/internal/storage" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -132,14 +132,15 @@ func TestApprovalRefusesLabelsTheRoleDoesNotGrant(t *testing.T) { if err != nil { t.Fatalf("GET /admin/enrollments: %v", err) } - var pending []storage.EnrollmentRequest - _ = json.NewDecoder(resp.Body).Decode(&pending) + listBody, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() + pending := &api.EnrollmentRequestListResponse{} + _ = protojson.Unmarshal(listBody, pending) var reqID string - for _, p := range pending { - if p.PeerID == pID.String() { - reqID = p.ID + for _, p := range pending.GetRequests() { + if p.GetPeerId() == pID.String() { + reqID = p.GetId() } } if reqID == "" { diff --git a/internal/controlplane/catalog.go b/internal/controlplane/catalog.go index 50ee2115..4886ca2a 100644 --- a/internal/controlplane/catalog.go +++ b/internal/controlplane/catalog.go @@ -24,6 +24,7 @@ import ( "github.com/google/sam/internal/storage" "github.com/libp2p/go-libp2p/core/peer" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) // maxCatalogServices bounds one report so a single admitted node cannot grow @@ -36,21 +37,6 @@ type nodeCatalogEntry struct { ReportedAt time.Time } -// catalogService is the console-facing shape of one reported service: a plain -// struct so the JSON the console reads does not depend on protoc-gen-go's -// struct layout or tags. -type catalogService struct { - Name string `json:"name"` - Type string `json:"type"` - Description string `json:"description"` -} - -// catalogView is HandleAdminStatus's node_catalog value for one peer. -type catalogView struct { - Services []catalogService `json:"services"` - ReportedAt time.Time `json:"reported_at"` -} - // catalogSnapshot returns a stable copy of the current node service catalog // cache, safe to range over without holding catalogMu. func (s *Server) catalogSnapshot() map[string]nodeCatalogEntry { @@ -66,9 +52,9 @@ func (s *Server) catalogSnapshot() map[string]nodeCatalogEntry { // catalogViewFor renders the cache for the console, restricted to nodes that // are still admitted so a banned or expired node's last report disappears // with its enrollment instead of lingering until the next restart. -func (s *Server) catalogViewFor(nodes []storage.EnrolledNode, now time.Time) map[string]catalogView { +func (s *Server) catalogViewFor(nodes []storage.EnrolledNode, now time.Time) map[string]*api.NodeServices { snap := s.catalogSnapshot() - view := make(map[string]catalogView, len(snap)) + view := make(map[string]*api.NodeServices, len(snap)) for i := range nodes { node := &nodes[i] // The cache is keyed by the canonical base58 form from the verified @@ -83,22 +69,14 @@ func (s *Server) catalogViewFor(nodes []storage.EnrolledNode, now time.Time) map if !ok || node.CheckAdmission(now) != nil { continue } - services := make([]catalogService, 0, len(entry.Services)) + services := make([]*api.ServiceInfo, 0, len(entry.Services)) for _, svc := range entry.Services { if svc == nil { continue } - typeName, err := api.ServiceTypeToString(svc.GetType()) - if err != nil { - typeName = "unknown" - } - services = append(services, catalogService{ - Name: svc.GetName(), - Type: typeName, - Description: svc.GetDescription(), - }) + services = append(services, svc) } - view[node.PeerID] = catalogView{Services: services, ReportedAt: entry.ReportedAt} + view[node.PeerID] = &api.NodeServices{Services: services, ReportTime: timestamppb.New(entry.ReportedAt)} } return view } diff --git a/internal/controlplane/catalog_test.go b/internal/controlplane/catalog_test.go index 4a1e2c73..c85054c7 100644 --- a/internal/controlplane/catalog_test.go +++ b/internal/controlplane/catalog_test.go @@ -19,7 +19,7 @@ import ( "context" "crypto/ed25519" "encoding/base64" - "encoding/json" + "io" "net/http" "strconv" "testing" @@ -29,6 +29,7 @@ import ( "github.com/google/sam/internal/identity" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -82,7 +83,7 @@ func catalogBody(t *testing.T, services ...*api.ServiceInfo) []byte { } // adminNodeCatalog fetches /admin/status and returns its node_catalog value. -func adminNodeCatalog(t *testing.T, cpURL, adminToken string) map[string]catalogView { +func adminNodeCatalog(t *testing.T, cpURL, adminToken string) map[string]*api.NodeServices { t.Helper() req, err := http.NewRequest(http.MethodGet, cpURL+"/admin/status", nil) @@ -98,13 +99,15 @@ func adminNodeCatalog(t *testing.T, cpURL, adminToken string) map[string]catalog if resp.StatusCode != http.StatusOK { t.Fatalf("GET /admin/status: status %d", resp.StatusCode) } - var status struct { - NodeCatalog map[string]catalogView `json:"node_catalog"` + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read /admin/status: %v", err) } - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + status := &api.AdminStatusResponse{} + if err := protojson.Unmarshal(body, status); err != nil { t.Fatalf("decode /admin/status: %v", err) } - return status.NodeCatalog + return status.GetNodeCatalog() } func TestHandleNodeCatalog(t *testing.T) { @@ -151,14 +154,14 @@ func TestHandleNodeCatalog(t *testing.T) { t.Fatalf("second report must replace the first, got %+v", entry.Services) } - // The console sees the plain view with the type rendered as a name. + // The console sees the reported services with the report time. view := adminNodeCatalog(t, cpURL, srv.config.AdminToken) got, ok := view[nodePeer.String()] if !ok || len(view) != 1 { t.Fatalf("expected the reporting peer in node_catalog, got %v", view) } - want := []catalogService{{Name: "planner", Type: "a2a", Description: ""}} - if len(got.Services) != 1 || got.Services[0] != want[0] || got.ReportedAt.IsZero() { + want := &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_A2A, Name: "planner"} + if len(got.GetServices()) != 1 || !proto.Equal(got.GetServices()[0], want) || got.GetReportTime() == nil { t.Fatalf("node_catalog view = %+v, want services %+v", got, want) } @@ -166,7 +169,7 @@ func TestHandleNodeCatalog(t *testing.T) { if got := postCatalog(t, cpURL, bearer(biscuitBytes), priv, catalogBody(t)); got != http.StatusNoContent { t.Fatalf("empty report: got status %d, want %d", got, http.StatusNoContent) } - if view := adminNodeCatalog(t, cpURL, srv.config.AdminToken); len(view[nodePeer.String()].Services) != 0 { + if view := adminNodeCatalog(t, cpURL, srv.config.AdminToken); len(view[nodePeer.String()].GetServices()) != 0 { t.Fatalf("empty report must clear services, got %+v", view) } } @@ -357,7 +360,7 @@ func TestCatalogPeerIDCanonicalization(t *testing.T) { // The view must join the CIDv1 record with the canonically-keyed entry, // displayed under the record's own spelling. view := adminNodeCatalog(t, cpURL, srv.config.AdminToken) - if _, ok := view[cidForm]; !ok || len(view[cidForm].Services) != 1 { + if _, ok := view[cidForm]; !ok || len(view[cidForm].GetServices()) != 1 { t.Fatalf("CIDv1-enrolled node's report missing from node_catalog, got %v", view) } diff --git a/internal/controlplane/identity_lifecycle_test.go b/internal/controlplane/identity_lifecycle_test.go index fc31d03b..eb12d1ea 100644 --- a/internal/controlplane/identity_lifecycle_test.go +++ b/internal/controlplane/identity_lifecycle_test.go @@ -34,6 +34,7 @@ import ( "github.com/google/sam/internal/storage" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -446,33 +447,40 @@ func TestUserStatusTrimsCredentialsAndMeshState(t *testing.T) { if status != http.StatusOK { t.Fatalf("/user/status: %d %s", status, body) } - for _, leaked := range []string{`"Biscuit"`, `"PublicKey"`, `"ClaimsJSON"`, `"policy_json"`, `"active_routers"`} { + for _, leaked := range []string{`"biscuit"`, `"public_key"`, `"claims_json"`, `"policy"`, `"active_routers"`} { if bytes.Contains(body, []byte(leaked)) { t.Errorf("non-admin /user/status carries %s", leaked) } } - var resp struct { - Nodes []map[string]any `json:"enrolled_nodes"` - } - if err := json.Unmarshal(body, &resp); err != nil { + resp := &api.UserStatusResponse{} + if err := protojson.Unmarshal(body, resp); err != nil { t.Fatal(err) } - if len(resp.Nodes) != 0 { + if len(resp.GetEnrolledNodes()) != 0 { // The node was OIDC-enrolled with no owner; a user sees only nodes it // owns. What matters is the shape when present, checked as admin. - t.Errorf("non-owner sees %d nodes", len(resp.Nodes)) + t.Errorf("non-owner sees %d nodes", len(resp.GetEnrolledNodes())) } + // protojson omits an empty list, so give the admin view a router to show. + if err := h.store.UpsertRouterLease(context.Background(), &storage.RouterLease{ + PeerID: "12D3KooWStatusRouter000000000000000000000000000000", + Addresses: []string{"/dns4/router.example/tcp/4501"}, + LastRenewal: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } status, body = h.do(http.MethodGet, "/user/status", "super-secret-admin-token", nil, "") if status != http.StatusOK { t.Fatalf("admin /user/status: %d %s", status, body) } - for _, leaked := range []string{`"Biscuit"`, `"PublicKey"`} { + for _, leaked := range []string{`"biscuit"`, `"public_key"`} { if bytes.Contains(body, []byte(leaked)) { t.Errorf("admin /user/status carries %s", leaked) } } - for _, wanted := range []string{`"policy_json"`, `"active_routers"`, `"ClaimsJSON"`, `"PeerID"`} { + for _, wanted := range []string{`"policy"`, `"active_routers"`, `"claims_json"`, `"peer_id"`} { if !bytes.Contains(body, []byte(wanted)) { t.Errorf("admin /user/status lacks %s", wanted) } diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index c59039e9..b0843c00 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -102,7 +102,11 @@ type Server struct { revokedBiscuitsMu sync.RWMutex revokedBiscuits map[string]time.Time - bannedNodeRevIDs map[string]string + // revokedPruneAt is the size at which RevokeBiscuitID next drops expired + // entries; it doubles after each pass, so a write costs O(1) amortized and + // the map holds at most twice its live entries. + revokedPruneAt int + bannedNodeRevIDs map[string]string oauthCodesMu sync.Mutex oauthCodes map[string]*oauthAuthCode @@ -188,6 +192,7 @@ func NewServer(config Options, store storage.Store) (*Server, error) { stsLimiter: stsLimiter, oidcSigner: signer, revokedBiscuits: make(map[string]time.Time), + revokedPruneAt: revokedPruneMin, bannedNodeRevIDs: make(map[string]string), oauthCodes: make(map[string]*oauthAuthCode), providers: make(map[string]*oidc.Provider), @@ -693,6 +698,13 @@ func (s *Server) HandleRegister(w http.ResponseWriter, r *http.Request) { http.Error(w, "JWT validation failed: "+err.Error(), http.StatusUnauthorized) return } + // Bans and ownership are keyed on issuer|subject; a token without a + // subject would enroll an identity that can never be banned. + if oidcIdentityKey(claims) == "" { + logger.Warnw("JWT without a subject refused", "peer_id", req.PeerId) + http.Error(w, "JWT validation failed: token has no sub claim", http.StatusUnauthorized) + return + } // An email the issuer marks unverified must not resolve bindings or be // minted as an email() fact. if verifiedEmail(claims) == "" { @@ -1716,24 +1728,22 @@ func (s *Server) HandleAdminPolicy(w http.ResponseWriter, r *http.Request) { if !s.checkAdminAuth(w, r) { return } - roles, bindings, err := s.store.GetMeshPolicy(r.Context()) - if err != nil && err != storage.ErrNotFound { + policy, err := s.loadPolicyConfig(r.Context()) + if err != nil { logger.Errorf("Failed to load policy: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - egress, err := s.store.GetEgressDestinations(r.Context()) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to load egress destinations: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - writeProtoJSON(w, &api.PolicyConfig{Roles: roles, Bindings: bindings, Egress: egress}) + writeProtoJSON(w, policy) } // writeProtoJSON answers an operator-plane request with protojson of msg, // using the proto field names the console and the docs show. func writeProtoJSON(w http.ResponseWriter, msg proto.Message) { + writeProtoJSONStatus(w, http.StatusOK, msg) +} + +func writeProtoJSONStatus(w http.ResponseWriter, status int, msg proto.Message) { out, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(msg) if err != nil { logger.Errorf("Failed to render %T: %v", msg, err) @@ -1741,10 +1751,33 @@ func writeProtoJSON(w http.ResponseWriter, msg proto.Message) { return } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + w.WriteHeader(status) _, _ = w.Write(out) } +// readProtoJSON decodes an operator-plane request body into msg. Unknown +// fields are an error: a misspelled field would otherwise silently become +// the default. Writes the 400 itself and reports false on failure. +func readProtoJSON(w http.ResponseWriter, r *http.Request, msg proto.Message) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + defer func() { _ = r.Body.Close() }() + body, err := io.ReadAll(r.Body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) + return false + } + http.Error(w, "Failed to read body", http.StatusBadRequest) + return false + } + if err := protojson.Unmarshal(body, msg); err != nil { + http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest) + return false + } + return true +} + // Close shuts down background loops and HTTP server. func (s *Server) Close() error { s.shutdown = true @@ -1752,7 +1785,11 @@ func (s *Server) Close() error { var errs []error if s.httpServer != nil { - if err := s.httpServer.Shutdown(context.Background()); err != nil { + // Bounded: a stuck connection must not hold the drain past what the + // orchestrator allows before it kills the process anyway. + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := s.httpServer.Shutdown(ctx); err != nil { errs = append(errs, err) } } @@ -1760,6 +1797,10 @@ func (s *Server) Close() error { return errors.Join(errs...) } +// shutdownTimeout bounds the HTTP drain in Close; it stays under the 30s +// Kubernetes grants a pod by default. +const shutdownTimeout = 20 * time.Second + // Addr returns the network address the server is listening on. func (s *Server) Addr() string { if s.listener == nil { @@ -2362,9 +2403,7 @@ func (s *Server) HandleAdminBootstrapTokens(w http.ResponseWriter, r *http.Reque http.Error(w, "Internal server error", http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(list) + writeProtoJSON(w, &api.BootstrapTokenListResponse{Tokens: bootstrapTokenViews(list)}) return } @@ -2373,24 +2412,21 @@ func (s *Server) HandleAdminBootstrapTokens(w http.ResponseWriter, r *http.Reque return } - var req api.BootstrapTokenRequest - r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON body", http.StatusBadRequest) + req := &api.BootstrapTokenCreateRequest{} + if !readProtoJSON(w, r, req) { return } - defer func() { _ = r.Body.Close() }() - if req.Role == "" { + if req.GetRole() == "" { // No silent default: the old one was router, the most privileged // role a token can carry. http.Error(w, "role is required (e.g. \"sam:role:node\")", http.StatusBadRequest) return } - if req.TTLHours <= 0 { - req.TTLHours = 24 + if req.GetTtlHours() <= 0 { + req.TtlHours = 24 } - if req.MaxUsages <= 0 { + if req.GetMaxUsages() <= 0 { req.MaxUsages = 1 } @@ -2405,13 +2441,13 @@ func (s *Server) HandleAdminBootstrapTokens(w http.ResponseWriter, r *http.Reque tokenRecord := &storage.BootstrapToken{ ID: tokenID, TokenHash: tokenID, - Role: req.Role, - MaxUsages: req.MaxUsages, + Role: req.GetRole(), + MaxUsages: int(req.GetMaxUsages()), UsagesCount: 0, - Description: req.Description, + Description: req.GetDescription(), CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(time.Duration(req.TTLHours) * time.Hour), - AutonomousRecovery: req.AutonomousRecovery, + ExpiresAt: time.Now().Add(time.Duration(req.GetTtlHours()) * time.Hour), + AutonomousRecovery: req.GetAutonomousRecovery(), } if err := s.store.SaveBootstrapToken(r.Context(), tokenRecord); err != nil { @@ -2420,13 +2456,11 @@ func (s *Server) HandleAdminBootstrapTokens(w http.ResponseWriter, r *http.Reque return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(api.BootstrapTokenResponse{ - ID: tokenRecord.ID, - Token: tokenVal, - Role: tokenRecord.Role, - ExpiresAt: tokenRecord.ExpiresAt.Format(time.RFC3339), + writeProtoJSONStatus(w, http.StatusCreated, &api.BootstrapTokenCreateResponse{ + Id: tokenRecord.ID, + Token: tokenVal, + Role: tokenRecord.Role, + ExpireTime: timestamppb.New(tokenRecord.ExpiresAt), }) } @@ -2484,14 +2518,7 @@ func (s *Server) HandleAdminEnrollments(w http.ResponseWriter, r *http.Request) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - for i := range list { - list[i].BiscuitToken = nil - list[i].PublicKey = nil - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(list) + writeProtoJSON(w, &api.EnrollmentRequestListResponse{Requests: enrollmentRequestViews(list)}) } // HandleAdminEnrollmentAction HTTP POST `/admin/enrollments/{id}/approve` or `/admin/enrollments/{id}/reject` @@ -2969,10 +2996,10 @@ func (s *Server) HandleUserStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - nodes := []enrolledNodeView{} - for _, n := range allNodes { - if isAdmin || n.OwnerID == user.ID { - nodes = append(nodes, viewEnrolledNode(n, isAdmin)) + nodes := []*api.EnrolledNode{} + for i := range allNodes { + if isAdmin || allNodes[i].OwnerID == user.ID { + nodes = append(nodes, enrolledNodeView(&allNodes[i], isAdmin)) } } @@ -2982,21 +3009,17 @@ func (s *Server) HandleUserStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - tokens := []storage.BootstrapToken{} - for _, t := range allTokens { - if isAdmin || t.OwnerID == user.ID { - tokens = append(tokens, t) + tokens := []*api.BootstrapToken{} + for i := range allTokens { + if isAdmin || allTokens[i].OwnerID == user.ID { + tokens = append(tokens, bootstrapTokenView(&allTokens[i])) } } - resp := map[string]any{ - "user": map[string]any{ - "id": user.ID, - "email": user.Email, - "role": user.Role, - }, - "enrolled_nodes": nodes, - "bootstrap_tokens": tokens, + resp := &api.UserStatusResponse{ + User: userView(user), + EnrolledNodes: nodes, + BootstrapTokens: tokens, } // The mesh policy and the router fleet describe the whole mesh, not the @@ -3008,65 +3031,32 @@ func (s *Server) HandleUserStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - resp["active_routers"] = routers + resp.ActiveRouters = routerLeaseViews(routers) - roles, bindings, err := s.store.GetMeshPolicy(ctx) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to list policy: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - egress, err := s.store.GetEgressDestinations(ctx) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to list egress destinations: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - rendered, err := marshalPolicyJSON(roles, bindings, egress) + policy, err := s.loadPolicyConfig(ctx) if err != nil { - logger.Errorf("Failed to render policy: %v", err) + logger.Errorf("Failed to list policy: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - resp["policy_json"] = rendered + resp.Policy = policy } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) -} - -// enrolledNodeView is what status endpoints return for a node: the record -// minus its live credential and key material. ClaimsJSON is admin-only. -type enrolledNodeView struct { - PeerID string `json:"PeerID"` - Role string `json:"Role"` - EnrollmentType string `json:"EnrollmentType"` - ClaimsJSON string `json:"ClaimsJSON,omitempty"` - OwnerID string `json:"OwnerID"` - Labels map[string]string `json:"Labels"` - EnrolledAt time.Time `json:"EnrolledAt"` - ExpiresAt time.Time `json:"ExpiresAt"` - Banned bool `json:"Banned"` - AutonomousRecovery bool `json:"AutonomousRecovery"` -} - -func viewEnrolledNode(n storage.EnrolledNode, withClaims bool) enrolledNodeView { - v := enrolledNodeView{ - PeerID: n.PeerID, - Role: n.Role, - EnrollmentType: n.EnrollmentType, - OwnerID: n.OwnerID, - Labels: n.Labels, - EnrolledAt: n.EnrolledAt, - ExpiresAt: n.ExpiresAt, - Banned: n.Banned, - AutonomousRecovery: n.AutonomousRecovery, - } - if withClaims { - v.ClaimsJSON = n.ClaimsJSON - } - return v + writeProtoJSON(w, resp) +} + +// loadPolicyConfig reads the stored mesh policy as the message POST /policies +// accepts; an empty store yields an empty policy. +func (s *Server) loadPolicyConfig(ctx context.Context) (*api.PolicyConfig, error) { + roles, bindings, err := s.store.GetMeshPolicy(ctx) + if err != nil && err != storage.ErrNotFound { + return nil, err + } + egress, err := s.store.GetEgressDestinations(ctx) + if err != nil && err != storage.ErrNotFound { + return nil, err + } + return &api.PolicyConfig{Roles: roles, Bindings: bindings, Egress: egress}, nil } // Ceilings on what a non-admin may mint for itself: a token is a standing @@ -3090,45 +3080,42 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques // AutonomousRecovery is admin-only here: it decides whether a lost device // can rejoin the mesh on its own. - var req api.BootstrapTokenRequest - r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON body", http.StatusBadRequest) + req := &api.BootstrapTokenCreateRequest{} + if !readProtoJSON(w, r, req) { return } - defer func() { _ = r.Body.Close() }() - if req.Role == "" { + if req.GetRole() == "" { req.Role = api.RoleNode } - if user.Role != "admin" && req.Role != api.RoleNode { + if user.Role != "admin" && req.GetRole() != api.RoleNode { http.Error(w, "Forbidden: Standard users can only generate tokens for node role", http.StatusForbidden) return } - if user.Role != "admin" && req.AutonomousRecovery { + if user.Role != "admin" && req.GetAutonomousRecovery() { http.Error(w, "Forbidden: only admins can issue tokens with autonomous_recovery", http.StatusForbidden) return } - ownerID, status, err := s.resolveTokenOwner(r.Context(), user, req.OwnerID) + ownerID, status, err := s.resolveTokenOwner(r.Context(), user, req.GetOwnerId()) if err != nil { http.Error(w, err.Error(), status) return } - if req.TTLHours <= 0 { - req.TTLHours = 24 + if req.GetTtlHours() <= 0 { + req.TtlHours = 24 } - if req.MaxUsages <= 0 { + if req.GetMaxUsages() <= 0 { req.MaxUsages = 1 } if user.Role != "admin" { - if req.TTLHours > userTokenMaxTTLHours { + if req.GetTtlHours() > userTokenMaxTTLHours { http.Error(w, fmt.Sprintf("ttl_hours may not exceed %d for non-admin users", userTokenMaxTTLHours), http.StatusBadRequest) return } - if req.MaxUsages > userTokenMaxUsages { + if req.GetMaxUsages() > userTokenMaxUsages { http.Error(w, fmt.Sprintf("max_usages may not exceed %d for non-admin users", userTokenMaxUsages), http.StatusBadRequest) return } @@ -3145,14 +3132,14 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques tokenRecord := &storage.BootstrapToken{ ID: tokenID, TokenHash: tokenID, - Role: req.Role, + Role: req.GetRole(), OwnerID: ownerID, - MaxUsages: req.MaxUsages, + MaxUsages: int(req.GetMaxUsages()), UsagesCount: 0, - Description: req.Description, + Description: req.GetDescription(), CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(time.Duration(req.TTLHours) * time.Hour), - AutonomousRecovery: req.AutonomousRecovery, + ExpiresAt: time.Now().Add(time.Duration(req.GetTtlHours()) * time.Hour), + AutonomousRecovery: req.GetAutonomousRecovery(), } if err := s.store.SaveBootstrapToken(r.Context(), tokenRecord); err != nil { @@ -3161,14 +3148,12 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(api.BootstrapTokenResponse{ - ID: tokenRecord.ID, - Token: tokenVal, - Role: tokenRecord.Role, - OwnerID: tokenRecord.OwnerID, - ExpiresAt: tokenRecord.ExpiresAt.Format(time.RFC3339), + writeProtoJSONStatus(w, http.StatusCreated, &api.BootstrapTokenCreateResponse{ + Id: tokenRecord.ID, + Token: tokenVal, + Role: tokenRecord.Role, + OwnerId: tokenRecord.OwnerID, + ExpireTime: timestamppb.New(tokenRecord.ExpiresAt), }) } @@ -3279,20 +3264,20 @@ func (s *Server) banNode(ctx context.Context, node *storage.EnrolledNode) error // is shed with a new keypair, and an unban that lifts only the peer id leaves // the human locked out of /register. func SetNodeBan(ctx context.Context, store storage.Store, node *storage.EnrolledNode, banned bool) error { - if err := store.SetNodeBanned(ctx, node.PeerID, banned); err != nil { - return err - } - if node.ClaimsJSON == "" { - return nil - } - var claims jwt.MapClaims - if err := json.Unmarshal([]byte(node.ClaimsJSON), &claims); err != nil { - return fmt.Errorf("stored claims for %s are unreadable: %w", node.PeerID, err) - } - if key := oidcIdentityKey(claims); key != "" { - return store.SetIdentityBanned(ctx, key, banned) + var identity string + if node.ClaimsJSON != "" { + var claims jwt.MapClaims + 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) + if identity == "" { + // /register refuses a token without a subject, so this is a record + // from before that check. The device ban must still land. + logger.Warnw("Enrollment record carries claims without a subject; banning the device only", "peer_id", node.PeerID) + } } - return nil + return store.SetNodeAndIdentityBanned(ctx, node.PeerID, identity, banned) } // allowedLabelPatterns collects the label grants of every role an identity @@ -3401,20 +3386,6 @@ func toStringSlice(val any) []string { return nil } -// marshalPolicyJSON renders the stored mesh policy as protojson using the proto -// field names. Generated marshalling is the point: a hand-maintained mirror of -// PolicyRole silently drops any field it forgets, which is how custom_datalog -// went missing from the console for so long. -func marshalPolicyJSON(roles []*api.PolicyRole, bindings []*api.PolicyBinding, egress []*api.EgressDestination) (string, error) { - resp := &api.PolicyConfig{Roles: roles, Bindings: bindings, Egress: egress} - marshaler := protojson.MarshalOptions{UseProtoNames: true, Multiline: true, Indent: " "} - out, err := marshaler.Marshal(resp) - if err != nil { - return "", err - } - return string(out), nil -} - // maxIdentityFactBudget bounds the worst-case number of Datalog facts a policy // config could let a single identity accumulate across all of its resolved // roles. biscuit-go's authorizer defaults to rejecting worlds beyond ~1000 diff --git a/internal/controlplane/server_test.go b/internal/controlplane/server_test.go index 40724c07..bb917958 100644 --- a/internal/controlplane/server_test.go +++ b/internal/controlplane/server_test.go @@ -619,10 +619,11 @@ func TestMarshalPolicyJSONRoundTrip(t *testing.T) { {Role: "ops", Members: []string{"user:root"}}, } - rendered, err := marshalPolicyJSON(roles, bindings, nil) + renderedBytes, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(&api.PolicyConfig{Roles: roles, Bindings: bindings}) if err != nil { t.Fatalf("rendering the policy: %v", err) } + rendered := string(renderedBytes) // Proto names, because that is what the docs and the Helm bootstrap job use. if !strings.Contains(rendered, "allowed_services") || !strings.Contains(rendered, "custom_datalog") { @@ -712,11 +713,11 @@ func TestPoliciesAcceptConsoleJSON(t *testing.T) { } // What /status hands the console must be postable back unchanged. - rendered, err := marshalPolicyJSON(roles, bindings, nil) + rendered, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(&api.PolicyConfig{Roles: roles, Bindings: bindings}) if err != nil { t.Fatalf("rendering the stored policy: %v", err) } - req, _ = http.NewRequest(http.MethodPost, baseURL+"/policies", strings.NewReader(rendered)) + req, _ = http.NewRequest(http.MethodPost, baseURL+"/policies", bytes.NewReader(rendered)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer super-secret-admin-token") resp, err = client.Do(req) @@ -1001,14 +1002,17 @@ func TestEnrollmentWorkflow(t *testing.T) { if err != nil { t.Fatal(err) } - var enrollList []storage.EnrollmentRequest - _ = json.NewDecoder(resp.Body).Decode(&enrollList) + enrollListBody, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() + enrollList := &api.EnrollmentRequestListResponse{} + if err := protojson.Unmarshal(enrollListBody, enrollList); err != nil { + t.Fatalf("decode /admin/enrollments: %v", err) + } - if len(enrollList) != 1 || enrollList[0].PeerID != pID.String() { + if len(enrollList.GetRequests()) != 1 || enrollList.GetRequests()[0].GetPeerId() != pID.String() { t.Fatalf("unexpected enrollments list: %+v", enrollList) } - reqID := enrollList[0].ID + reqID := enrollList.GetRequests()[0].GetId() // Approve req, _ = http.NewRequest("POST", baseURL+"/admin/enrollments/"+reqID+"/approve", nil) @@ -2208,29 +2212,20 @@ func TestAdminPanelAndUI(t *testing.T) { t.Errorf("expected 200 status for authenticated status query, got: %d", resp.StatusCode) } - var statusData map[string]any - if err := json.NewDecoder(resp.Body).Decode(&statusData); err != nil { - t.Fatalf("failed to decode admin status response: %v", err) + statusBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read admin status response: %v", err) } _ = resp.Body.Close() - - if _, ok := statusData["active_routers"]; !ok { - t.Error("status response missing active_routers") - } - if _, ok := statusData["enrolled_nodes"]; !ok { - t.Error("status response missing enrolled_nodes") - } - if _, ok := statusData["enrollment_requests"]; !ok { - t.Error("status response missing enrollment_requests") - } - if _, ok := statusData["bootstrap_tokens"]; !ok { - t.Error("status response missing bootstrap_tokens") + statusData := &api.AdminStatusResponse{} + if err := protojson.Unmarshal(statusBody, statusData); err != nil { + t.Fatalf("failed to decode admin status response: %v", err) } - users, ok := statusData["users"].([]any) - if !ok || len(users) != 1 { - t.Fatalf("expected 1 user in status response, got: %v", statusData["users"]) + + if len(statusData.GetUsers()) != 1 { + t.Fatalf("expected 1 user in status response, got: %v", statusData.GetUsers()) } - if got := users[0].(map[string]any)["ID"]; got != testUser.ID { + if got := statusData.GetUsers()[0].GetId(); got != testUser.ID { t.Errorf("expected user ID %q, got %v", testUser.ID, got) } @@ -2341,20 +2336,19 @@ func TestUserStatusAndTenancy(t *testing.T) { t.Fatalf("expected 200, got: %d", resp.StatusCode) } - var data map[string]interface{} - _ = json.NewDecoder(resp.Body).Decode(&data) + statusBody, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() - - enrolledNodes, ok := data["enrolled_nodes"].([]interface{}) - if !ok { - t.Fatal("enrolled_nodes is not an array") + data := &api.UserStatusResponse{} + if err := protojson.Unmarshal(statusBody, data); err != nil { + t.Fatalf("decode /user/status: %v", err) } + + enrolledNodes := data.GetEnrolledNodes() if len(enrolledNodes) != 1 { t.Fatalf("expected 1 node for User A, got: %d", len(enrolledNodes)) } - nodeMap := enrolledNodes[0].(map[string]interface{}) - if nodeMap["PeerID"] != peerA.String() { - t.Errorf("expected node %q, got: %s", peerA.String(), nodeMap["PeerID"]) + if enrolledNodes[0].GetPeerId() != peerA.String() { + t.Errorf("expected node %q, got: %s", peerA.String(), enrolledNodes[0].GetPeerId()) } reqRevoke, _ := http.NewRequest("POST", baseURL+"/user/revoke?id="+peerB.String(), nil) @@ -2592,6 +2586,37 @@ func TestAuthDenialPaths(t *testing.T) { } }) + t.Run("oversized operator-plane body answers 413", func(t *testing.T) { + oversized := bytes.Repeat([]byte("a"), maxRequestBodyBytes+1) + req, _ := http.NewRequest(http.MethodPost, baseURL+"/admin/bootstrap-tokens", bytes.NewReader(oversized)) + req.Header.Set("Authorization", "Bearer super-secret-admin-token") + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Errorf("expected 413 for oversized JSON body, got %d", resp.StatusCode) + } + }) + + t.Run("JWT without a subject is rejected", func(t *testing.T) { + // Bans are keyed on issuer|subject; an identity without one could + // never be banned, so it never enrolls. + noSubJWT := mintToken(map[string]interface{}{ + "email": "nobody@example.com", + }) + resp, err := client.Post(baseURL+"/register", "application/x-protobuf", bytes.NewReader(newEnrollBody(noSubJWT))) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 for a JWT without sub, got %d", resp.StatusCode) + } + }) + t.Run("admin endpoint rejects wrong token", func(t *testing.T) { req, _ := http.NewRequest("GET", baseURL+"/admin/status", nil) req.Header.Set("Authorization", "Bearer this-is-not-the-admin-token") @@ -3184,16 +3209,20 @@ func TestAdminBootstrapTokensList(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("GET /admin/bootstrap-tokens status = %s, want 200", resp.Status) } - var list []storage.BootstrapToken - if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { - t.Fatalf("failed to decode token list: %v", err) + listBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read token list: %v", err) } _ = resp.Body.Close() - if len(list) != 1 { - t.Fatalf("token list length = %d, want 1", len(list)) + list := &api.BootstrapTokenListResponse{} + if err := protojson.Unmarshal(listBody, list); err != nil { + t.Fatalf("failed to decode token list: %v", err) + } + if len(list.GetTokens()) != 1 { + t.Fatalf("token list length = %d, want 1", len(list.GetTokens())) } - if list[0].Role != "sam:role:node" || list[0].MaxUsages != 3 || list[0].Description != "cli test" { - t.Errorf("unexpected token record: %+v", list[0]) + if tok := list.GetTokens()[0]; tok.GetRole() != "sam:role:node" || tok.GetMaxUsages() != 3 || tok.GetDescription() != "cli test" { + t.Errorf("unexpected token record: %+v", tok) } // GET without credentials is refused. @@ -3672,17 +3701,19 @@ func TestAdminRevokeBootstrapToken(t *testing.T) { if err != nil { t.Fatal(err) } - var listed []struct { - ID string - } - if err := json.NewDecoder(listResp.Body).Decode(&listed); err != nil { + listedBody, err := io.ReadAll(listResp.Body) + if err != nil { t.Fatal(err) } _ = listResp.Body.Close() - if len(listed) != 1 { - t.Fatalf("GET /admin/bootstrap-tokens returned %d tokens, want 1", len(listed)) + listed := &api.BootstrapTokenListResponse{} + if err := protojson.Unmarshal(listedBody, listed); err != nil { + t.Fatal(err) + } + if len(listed.GetTokens()) != 1 { + t.Fatalf("GET /admin/bootstrap-tokens returned %d tokens, want 1", len(listed.GetTokens())) } - tokenID := listed[0].ID + tokenID := listed.GetTokens()[0].GetId() priv, pub, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) if err != nil { diff --git a/internal/controlplane/sts.go b/internal/controlplane/sts.go index b6cd076a..e27e72ee 100644 --- a/internal/controlplane/sts.go +++ b/internal/controlplane/sts.go @@ -420,6 +420,10 @@ func (s *Server) allowNodeSTSRequest(peerID string) bool { return s.stsLimiter.Allow(peerID) } +// revokedPruneMin is the smallest revocation set RevokeBiscuitID bothers to +// sweep for expired entries. +const revokedPruneMin = 64 + // RevokeBiscuitID records a root Biscuit revocation ID (base64url-encoded) as // revoked until expiry. func (s *Server) RevokeBiscuitID(revocationID string, expiry time.Time) { @@ -429,7 +433,18 @@ func (s *Server) RevokeBiscuitID(revocationID string, expiry time.Time) { if expiry.IsZero() { expiry = time.Now().Add(s.config.BiscuitTTL) } + now := time.Now() s.revokedBiscuitsMu.Lock() + // Expired entries also go on read; dropping them here too keeps the map + // bounded when nothing polls /revocations. + if len(s.revokedBiscuits) >= s.revokedPruneAt { + for id, exp := range s.revokedBiscuits { + if !now.Before(exp) { + delete(s.revokedBiscuits, id) + } + } + s.revokedPruneAt = max(revokedPruneMin, 2*len(s.revokedBiscuits)) + } s.revokedBiscuits[revocationID] = expiry s.revokedBiscuitsMu.Unlock() if s.store != nil { diff --git a/internal/controlplane/sts_test.go b/internal/controlplane/sts_test.go index 9a074d0c..4f44ef5a 100644 --- a/internal/controlplane/sts_test.go +++ b/internal/controlplane/sts_test.go @@ -24,6 +24,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "fmt" "io" "net/http" "net/url" @@ -932,3 +933,27 @@ func TestOAuthAndControlPlaneHardening(t *testing.T) { t.Fatalf("expected 415 for text/plain POST /policies, got %d", polResp.StatusCode) } } + +// Expired revocations leave the in-memory set as writes accumulate, not only +// when /revocations is read, and the set stays bounded by its live entries. +func TestRevokeBiscuitIDPrunesExpiredEntries(t *testing.T) { + srv, store, _ := setupTestServer(t, "") + defer func() { + _ = srv.Close() + _ = store.Close() + }() + + for i := 0; i < 4*revokedPruneMin; i++ { + srv.RevokeBiscuitID(fmt.Sprintf("expired-%d", i), time.Now().Add(-time.Minute)) + } + srv.RevokeBiscuitID("live", time.Now().Add(time.Hour)) + + srv.revokedBiscuitsMu.RLock() + defer srv.revokedBiscuitsMu.RUnlock() + if n := len(srv.revokedBiscuits); n > revokedPruneMin+1 { + t.Errorf("revocation set holds %d entries after %d expired writes, want at most %d", n, 4*revokedPruneMin, revokedPruneMin+1) + } + if _, ok := srv.revokedBiscuits["live"]; !ok { + t.Error("live revocation missing") + } +} diff --git a/internal/controlplane/ui.go b/internal/controlplane/ui.go index 2b906412..530cba6f 100644 --- a/internal/controlplane/ui.go +++ b/internal/controlplane/ui.go @@ -15,14 +15,14 @@ package controlplane import ( - "encoding/json" "net/http" "time" - "github.com/google/sam/internal/storage" + "github.com/google/sam/api" ) -// HandleAdminStatus returns a consolidated JSON state of the control plane. +// HandleAdminStatus HTTP GET `/admin/status`: the whole mesh as the console +// shows it, protojson of AdminStatusResponse. func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { if !s.checkAdminAuth(w, r) { return @@ -55,10 +55,6 @@ func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - for i := range reqs { - reqs[i].BiscuitToken = nil - reqs[i].PublicKey = nil - } tokens, err := s.store.ListBootstrapTokens(ctx) if err != nil { @@ -74,37 +70,25 @@ func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { return } - roles, bindings, err := s.store.GetMeshPolicy(r.Context()) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to list policy: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - egress, err := s.store.GetEgressDestinations(r.Context()) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to list egress destinations: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - policyJSON, err := marshalPolicyJSON(roles, bindings, egress) + policy, err := s.loadPolicyConfig(ctx) if err != nil { - logger.Errorf("Failed to render policy: %v", err) + logger.Errorf("Failed to list policy: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - resp := map[string]any{ - "users": users, - "active_routers": routers, - "enrolled_nodes": nodes, - "enrollment_requests": reqs, - "bootstrap_tokens": tokens, - "policy_json": policyJSON, - "node_catalog": s.catalogViewFor(nodes, time.Now()), + enrolled := make([]*api.EnrolledNode, 0, len(nodes)) + for i := range nodes { + enrolled = append(enrolled, enrolledNodeView(&nodes[i], true)) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + writeProtoJSON(w, &api.AdminStatusResponse{ + Users: userViews(users), + ActiveRouters: routerLeaseViews(routers), + EnrolledNodes: enrolled, + EnrollmentRequests: enrollmentRequestViews(reqs), + BootstrapTokens: bootstrapTokenViews(tokens), + Policy: policy, + NodeCatalog: s.catalogViewFor(nodes, time.Now()), + }) } diff --git a/internal/controlplane/views.go b/internal/controlplane/views.go new file mode 100644 index 00000000..a38519e1 --- /dev/null +++ b/internal/controlplane/views.go @@ -0,0 +1,143 @@ +// Copyright 2026 Google LLC +// +// 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 controlplane + +import ( + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/sam/api" + "github.com/google/sam/internal/storage" +) + +// The operator plane (/admin/*, /user/*) serves api.proto messages; these +// render the store's records into them, leaving credentials and key material +// behind. + +// timestampOrNil keeps a zero time unset, which protojson omits. +func timestampOrNil(t time.Time) *timestamppb.Timestamp { + if t.IsZero() { + return nil + } + return timestamppb.New(t) +} + +func bootstrapTokenView(t *storage.BootstrapToken) *api.BootstrapToken { + v := &api.BootstrapToken{ + Id: t.ID, + Role: t.Role, + OwnerId: t.OwnerID, + MaxUsages: int32(t.MaxUsages), + UsagesCount: int32(t.UsagesCount), + Description: t.Description, + CreateTime: timestampOrNil(t.CreatedAt), + ExpireTime: timestampOrNil(t.ExpiresAt), + AutonomousRecovery: t.AutonomousRecovery, + } + if t.RevokedAt != nil { + v.RevokeTime = timestamppb.New(*t.RevokedAt) + } + return v +} + +func bootstrapTokenViews(list []storage.BootstrapToken) []*api.BootstrapToken { + out := make([]*api.BootstrapToken, 0, len(list)) + for i := range list { + out = append(out, bootstrapTokenView(&list[i])) + } + return out +} + +func enrollmentRequestView(r *storage.EnrollmentRequest) *api.EnrollmentRequest { + v := &api.EnrollmentRequest{ + Id: r.ID, + PeerId: r.PeerID, + TokenId: r.TokenID, + Status: r.Status, + Labels: r.Labels, + CreateTime: timestampOrNil(r.CreatedAt), + ResolvedBy: r.ResolvedBy, + } + if r.ResolvedAt != nil { + v.ResolveTime = timestamppb.New(*r.ResolvedAt) + } + return v +} + +func enrollmentRequestViews(list []storage.EnrollmentRequest) []*api.EnrollmentRequest { + out := make([]*api.EnrollmentRequest, 0, len(list)) + for i := range list { + out = append(out, enrollmentRequestView(&list[i])) + } + return out +} + +func userView(u *storage.User) *api.User { + return &api.User{ + Id: u.ID, + Issuer: u.Issuer, + Email: u.Email, + Role: u.Role, + CreateTime: timestampOrNil(u.CreatedAt), + } +} + +func userViews(list []storage.User) []*api.User { + out := make([]*api.User, 0, len(list)) + for i := range list { + out = append(out, userView(&list[i])) + } + return out +} + +// enrolledNodeView drops the node's biscuit and public key. The identity +// provider's claims are admin material. +func enrolledNodeView(n *storage.EnrolledNode, withClaims bool) *api.EnrolledNode { + v := &api.EnrolledNode{ + PeerId: n.PeerID, + Role: n.Role, + EnrollmentType: n.EnrollmentType, + OwnerId: n.OwnerID, + Labels: n.Labels, + EnrollTime: timestampOrNil(n.EnrolledAt), + ExpireTime: timestampOrNil(n.ExpiresAt), + Banned: n.Banned, + AutonomousRecovery: n.AutonomousRecovery, + } + if withClaims { + v.ClaimsJson = n.ClaimsJSON + } + return v +} + +func routerLeaseView(l *storage.RouterLease) *api.RouterLease { + return &api.RouterLease{ + PeerId: l.PeerID, + Addresses: l.Addresses, + LastRenewalTime: timestampOrNil(l.LastRenewal), + ExpireTime: timestampOrNil(l.ExpiresAt), + ConnectedPeers: l.ConnectedPeers, + DhtSize: int32(l.DHTSize), + } +} + +func routerLeaseViews(list []storage.RouterLease) []*api.RouterLease { + out := make([]*api.RouterLease, 0, len(list)) + for i := range list { + out = append(out, routerLeaseView(&list[i])) + } + return out +} diff --git a/internal/node/identity_evidence.go b/internal/node/identity_evidence.go index 9a435c85..b489cfe6 100644 --- a/internal/node/identity_evidence.go +++ b/internal/node/identity_evidence.go @@ -112,7 +112,7 @@ func requireIdentityEvidenceTransport(next http.Handler) http.Handler { } func writeEvidenceProtoJSON(w http.ResponseWriter, status int, value proto.Message) { - body, err := protojson.Marshal(value) + body, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(value) if err != nil { logger.Errorf("[IdentityEvidence] Failed to encode response: %v", err) writeEvidenceError(w, http.StatusInternalServerError, "Failed to encode response") diff --git a/internal/node/openai_facade.go b/internal/node/openai_facade.go index d6ff9875..07c6b6a8 100644 --- a/internal/node/openai_facade.go +++ b/internal/node/openai_facade.go @@ -467,11 +467,14 @@ func (f *openAIFacade) handleCompletions(w http.ResponseWriter, r *http.Request) } func (f *openAIFacade) serveLocal(w http.ResponseWriter, r *http.Request, serviceName string) { - if f.authorizeLocal != nil { - if err := f.authorizeLocal(r.Context(), serviceName, r.Method, r.URL.Path); err != nil { - writeOpenAIError(w, http.StatusForbidden, "permission_denied", fmt.Sprintf("authorization failed: %v", err)) - return - } + if f.authorizeLocal == nil { + writeOpenAIError(w, http.StatusServiceUnavailable, "authorization_unavailable", + "local authorization is unavailable on this node") + return + } + if err := f.authorizeLocal(r.Context(), serviceName, r.Method, r.URL.Path); err != nil { + writeOpenAIError(w, http.StatusForbidden, "permission_denied", fmt.Sprintf("authorization failed: %v", err)) + return } for _, svc := range f.localServices() { if svc.Info().GetName() != serviceName || svc.Handler() == nil { diff --git a/internal/node/openai_facade_test.go b/internal/node/openai_facade_test.go index 9818ca6e..93678150 100644 --- a/internal/node/openai_facade_test.go +++ b/internal/node/openai_facade_test.go @@ -50,8 +50,9 @@ func newFakeModelService(name string, handler http.Handler, models ...string) *f } // newTestFacade builds a facade with inert seams; tests override as needed. -// The provider verifier accepts everyone: tests that care about the gate -// replace it, and tests of the fail-closed path set it to nil explicitly. +// The provider verifier and the local authorizer accept everyone: tests that +// care about a gate replace it, and tests of the fail-closed path set it to +// nil explicitly. func newTestFacade() *openAIFacade { return &openAIFacade{ ttl: time.Minute, @@ -64,6 +65,7 @@ func newTestFacade() *openAIFacade { return nil, nil }, verifyPeerLabels: func(context.Context, string, map[string]string) error { return nil }, + authorizeLocal: func(context.Context, string, string, string) error { return nil }, } } @@ -237,6 +239,29 @@ func TestFacade_Completions_PrefersLocal(t *testing.T) { } } +// A facade without a local authorizer must not serve local models: the +// seam is wiring, and missing wiring is a refusal, not an allow. +func TestFacade_Completions_LocalWithoutAuthorizerFailsClosed(t *testing.T) { + f := newTestFacade() + f.authorizeLocal = nil + localHit := false + local := newFakeModelService("local-llm", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + localHit = true + }), "m1") + f.localServices = func() []Service { return []Service{local} } + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m1"}`)) + rec := httptest.NewRecorder() + f.handleCompletions(rec, req) + + if localHit { + t.Fatal("local service was invoked without authorization") + } + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusServiceUnavailable, rec.Body.String()) + } +} + func TestFacade_Completions_UnknownModel(t *testing.T) { f := newTestFacade() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"nope"}`)) diff --git a/internal/storage/migration_test.go b/internal/storage/migration_test.go new file mode 100644 index 00000000..45d670dd --- /dev/null +++ b/internal/storage/migration_test.go @@ -0,0 +1,183 @@ +// Copyright 2026 Google LLC +// +// 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 storage + +import ( + "context" + "database/sql" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/google/sam/api" +) + +// openAtSchemaVersion creates a sqlite database migrated up to and including +// version, the way a control plane of that era left it. +func openAtSchemaVersion(t *testing.T, path string, version int) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + for _, m := range migrations { + if m.version > version { + break + } + for _, q := range m.sqlite { + if _, err := db.Exec(q); err != nil { + t.Fatalf("migration %d: %v", m.version, err) + } + } + if _, err := db.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, m.version); err != nil { + t.Fatal(err) + } + } + return db +} + +// Before schema version 15 four tables stored instants as unix seconds while +// the rest of the schema used milliseconds. The migration rewrites those +// rows once, and leaves rows that already hold milliseconds alone. +func TestMigrationConvertsSecondsToMillis(t *testing.T) { + path := filepath.Join(t.TempDir(), "cp.db") + created := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + expires := created.Add(48 * time.Hour) + revoked := created.Add(time.Hour) + resolved := created.Add(2 * time.Minute) + + db := openAtSchemaVersion(t, path, 14) + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.Exec(q, args...); err != nil { + t.Fatalf("%s: %v", q, err) + } + } + exec(`INSERT INTO users (id, issuer, email, role, created_at) VALUES ('alice', 'https://idp', 'alice@example.com', 'user', ?)`, created.Unix()) + exec(`INSERT INTO bootstrap_tokens (id, token_hash, role, max_usages, usages_count, description, created_at, expires_at, revoked_at) + VALUES ('old', 'old', 'sam:role:node', 2, 0, 'seconds era', ?, ?, NULL)`, created.Unix(), expires.Unix()) + exec(`INSERT INTO bootstrap_tokens (id, token_hash, role, max_usages, usages_count, description, created_at, expires_at, revoked_at) + VALUES ('old-revoked', 'old-revoked', 'sam:role:node', 1, 0, 'seconds era', ?, ?, ?)`, created.Unix(), expires.Unix(), revoked.Unix()) + // A row written in milliseconds must come through the guard untouched. + exec(`INSERT INTO bootstrap_tokens (id, token_hash, role, max_usages, usages_count, description, created_at, expires_at, revoked_at) + VALUES ('new', 'new', 'sam:role:node', 1, 0, 'millis era', ?, ?, NULL)`, created.UnixMilli(), expires.UnixMilli()) + exec(`INSERT INTO enrollment_requests (id, peer_id, public_key, token_id, status, created_at, resolved_at, resolved_by) + VALUES ('req', '12D3KooWPeer', X'00', 'old', ?, ?, ?, 'admin')`, int(api.EnrollmentStatus_ENROLLMENT_STATUS_APPROVED), created.Unix(), resolved.Unix()) + exec(`INSERT INTO banned_identities (identity, banned_at) VALUES ('https://idp|mallory', ?)`, created.Unix()) + if err := db.Close(); err != nil { + t.Fatal(err) + } + + store, err := NewSQLStore("sqlite", path) + if err != nil { + t.Fatalf("opening the store runs the migration: %v", err) + } + defer func() { _ = store.Close() }() + ctx := context.Background() + + var version int + if err := store.db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version); err != nil { + t.Fatal(err) + } + if version < 15 { + t.Fatalf("schema version = %d, want at least 15", version) + } + + user, err := store.GetUser(ctx, "alice") + if err != nil { + t.Fatal(err) + } + if !user.CreatedAt.Equal(created) { + t.Errorf("user created_at = %v, want %v", user.CreatedAt, created) + } + + for _, id := range []string{"old", "new"} { + tok, err := store.GetBootstrapToken(ctx, id) + if err != nil { + t.Fatal(err) + } + if !tok.CreatedAt.Equal(created) || !tok.ExpiresAt.Equal(expires) || tok.RevokedAt != nil { + t.Errorf("token %s = created %v expires %v revoked %v; want %v %v nil", id, tok.CreatedAt, tok.ExpiresAt, tok.RevokedAt, created, expires) + } + } + tok, err := store.GetBootstrapToken(ctx, "old-revoked") + if err != nil { + t.Fatal(err) + } + if tok.RevokedAt == nil || !tok.RevokedAt.Equal(revoked) { + t.Errorf("token old-revoked revoked_at = %v, want %v", tok.RevokedAt, revoked) + } + // The usability check compares expires_at against the clock in the same + // unit; a token still valid in its own era stays valid. + if err := store.ConsumeBootstrapTokenUsage(ctx, "old", expires.Add(-time.Minute)); err != nil { + t.Errorf("consuming a converted token before its expiry: %v", err) + } + if err := store.ConsumeBootstrapTokenUsage(ctx, "old", expires.Add(time.Minute)); err == nil { + t.Error("consuming a converted token after its expiry succeeded") + } + + req, err := store.GetEnrollmentRequestByID(ctx, "req") + if err != nil { + t.Fatal(err) + } + if !req.CreatedAt.Equal(created) || req.ResolvedAt == nil || !req.ResolvedAt.Equal(resolved) { + t.Errorf("enrollment request = created %v resolved %v; want %v %v", req.CreatedAt, req.ResolvedAt, created, resolved) + } + + var bannedAt int64 + if err := store.db.QueryRow(`SELECT banned_at FROM banned_identities WHERE identity = 'https://idp|mallory'`).Scan(&bannedAt); err != nil { + t.Fatal(err) + } + if bannedAt != created.UnixMilli() { + t.Errorf("banned_at = %d, want %d", bannedAt, created.UnixMilli()) + } +} + +// Migration 16 indexes the scans that run on every bootstrap and every GC +// pass; the index list is the contract the planner relies on. +func TestMigrationCreatesIndexes(t *testing.T) { + store, err := NewSQLStore("sqlite", filepath.Join(t.TempDir(), "cp.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + + rows, err := store.db.Query(`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%' ORDER BY name`) + if err != nil { + t.Fatal(err) + } + defer func() { _ = rows.Close() }() + var got []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + got = append(got, name) + } + want := []string{ + "idx_enrollment_requests_created_at", + "idx_nodes_banned_expires_at", + "idx_revoked_biscuits_expires_at", + "idx_routers_expires_at", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("indexes = %v, want %v", got, want) + } +} diff --git a/internal/storage/round_trip_test.go b/internal/storage/round_trip_test.go index 23f206b8..312d9d48 100644 --- a/internal/storage/round_trip_test.go +++ b/internal/storage/round_trip_test.go @@ -88,19 +88,19 @@ func requireFieldsRoundTrip(t *testing.T, want, got any, skip ...string) { } } -// valuesEqual compares at the coarsest precision any of these columns store, -// which is whole seconds for the timestamps written with Unix(). +// valuesEqual compares timestamps at the precision the columns store, unix +// milliseconds, so a table that fell back to seconds would fail here. func valuesEqual(w, g reflect.Value) bool { switch wv := w.Interface().(type) { case time.Time: gv, ok := g.Interface().(time.Time) - return ok && wv.Unix() == gv.Unix() + return ok && wv.UnixMilli() == gv.UnixMilli() case *time.Time: gv, ok := g.Interface().(*time.Time) if !ok || (wv == nil) != (gv == nil) { return false } - return wv == nil || wv.Unix() == gv.Unix() + return wv == nil || wv.UnixMilli() == gv.UnixMilli() } return reflect.DeepEqual(w.Interface(), g.Interface()) } diff --git a/internal/storage/sql_store.go b/internal/storage/sql_store.go index a93d0fda..913d886d 100644 --- a/internal/storage/sql_store.go +++ b/internal/storage/sql_store.go @@ -519,6 +519,39 @@ var migrations = []migration{ )`, }, }, + { + // Every BIGINT instant is unix milliseconds. These four tables held + // seconds; the guard tells the two apart (10^11 seconds is the year + // 5138, 10^11 milliseconds is 1973), so a row is converted once. + version: 15, + postgres: secondsToMillisMigration, + sqlite: secondsToMillisMigration, + }, + { + // 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. + version: 16, + postgres: indexMigration, + sqlite: indexMigration, + }, +} + +var indexMigration = []string{ + `CREATE INDEX IF NOT EXISTS idx_nodes_banned_expires_at ON nodes (banned, expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_routers_expires_at ON routers (expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_revoked_biscuits_expires_at ON revoked_biscuits (expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_enrollment_requests_created_at ON enrollment_requests (created_at)`, +} + +var secondsToMillisMigration = []string{ + `UPDATE bootstrap_tokens SET created_at = created_at * 1000 WHERE created_at < 100000000000`, + `UPDATE bootstrap_tokens SET expires_at = expires_at * 1000 WHERE expires_at < 100000000000`, + `UPDATE bootstrap_tokens SET revoked_at = revoked_at * 1000 WHERE revoked_at IS NOT NULL AND revoked_at < 100000000000`, + `UPDATE enrollment_requests SET created_at = created_at * 1000 WHERE created_at < 100000000000`, + `UPDATE enrollment_requests SET resolved_at = resolved_at * 1000 WHERE resolved_at IS NOT NULL AND resolved_at < 100000000000`, + `UPDATE users SET created_at = created_at * 1000 WHERE created_at < 100000000000`, + `UPDATE banned_identities SET banned_at = banned_at * 1000 WHERE banned_at < 100000000000`, } func (s *SQLStore) initSchema() error { @@ -938,7 +971,7 @@ func (s *SQLStore) SetIdentityBanned(ctx context.Context, identity string, banne } if banned { query := s.rebind(`INSERT INTO banned_identities (identity, banned_at) VALUES (?, ?) ON CONFLICT (identity) DO NOTHING`) - _, err := s.db.ExecContext(ctx, query, identity, time.Now().Unix()) + _, err := s.db.ExecContext(ctx, query, identity, time.Now().UnixMilli()) return err } query := s.rebind(`DELETE FROM banned_identities WHERE identity = ?`) @@ -946,6 +979,38 @@ func (s *SQLStore) SetIdentityBanned(ctx context.Context, identity string, banne return err } +// SetNodeAndIdentityBanned implements Store. +func (s *SQLStore) SetNodeAndIdentityBanned(ctx context.Context, peerID, identity string, banned bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.ExecContext(ctx, s.rebind(`UPDATE nodes SET banned = ? WHERE peer_id = ?`), banned, peerID) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + if identity != "" { + if banned { + _, err = tx.ExecContext(ctx, s.rebind(`INSERT INTO banned_identities (identity, banned_at) VALUES (?, ?) ON CONFLICT (identity) DO NOTHING`), identity, time.Now().UnixMilli()) + } else { + _, err = tx.ExecContext(ctx, s.rebind(`DELETE FROM banned_identities WHERE identity = ?`), identity) + } + if err != nil { + return err + } + } + return tx.Commit() +} + // IsIdentityBanned implements Store. func (s *SQLStore) IsIdentityBanned(ctx context.Context, identity string) (bool, error) { query := s.rebind(`SELECT 1 FROM banned_identities WHERE identity = ?`) @@ -1337,8 +1402,8 @@ func (s *SQLStore) SaveBootstrapToken(ctx context.Context, token *BootstrapToken token.MaxUsages, token.UsagesCount, token.Description, - token.CreatedAt.Unix(), - token.ExpiresAt.Unix(), + token.CreatedAt.UnixMilli(), + token.ExpiresAt.UnixMilli(), token.AutonomousRecovery, ) return err @@ -1373,10 +1438,10 @@ func (s *SQLStore) GetBootstrapToken(ctx context.Context, id string) (*Bootstrap if ownerID.Valid { t.OwnerID = ownerID.String } - t.CreatedAt = time.Unix(created, 0) - t.ExpiresAt = time.Unix(expires, 0) + t.CreatedAt = time.UnixMilli(created) + t.ExpiresAt = time.UnixMilli(expires) if revokedAt.Valid { - rt := time.Unix(revokedAt.Int64, 0) + rt := time.UnixMilli(revokedAt.Int64) t.RevokedAt = &rt } return &t, nil @@ -1387,7 +1452,7 @@ func (s *SQLStore) GetBootstrapToken(ctx context.Context, id string) (*Bootstrap func (s *SQLStore) ConsumeBootstrapTokenUsage(ctx context.Context, id string, now time.Time) error { query := `UPDATE bootstrap_tokens SET usages_count = usages_count + 1 WHERE id = ? AND usages_count < max_usages AND revoked_at IS NULL AND expires_at > ?` - res, err := s.db.ExecContext(ctx, s.rebind(query), id, now.Unix()) + res, err := s.db.ExecContext(ctx, s.rebind(query), id, now.UnixMilli()) if err != nil { return fmt.Errorf("failed to consume token usage: %w", err) } @@ -1406,7 +1471,7 @@ func (s *SQLStore) ConsumeBootstrapTokenUsage(ctx context.Context, id string, no // a no-op rather than clobbering the original revocation time. func (s *SQLStore) RevokeBootstrapToken(ctx context.Context, id string) error { query := `UPDATE bootstrap_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL` - _, err := s.db.ExecContext(ctx, s.rebind(query), time.Now().Unix(), id) + _, err := s.db.ExecContext(ctx, s.rebind(query), time.Now().UnixMilli(), id) if err != nil { return fmt.Errorf("failed to revoke bootstrap token: %w", err) } @@ -1423,7 +1488,7 @@ func (s *SQLStore) CreateEnrollmentRequest(ctx context.Context, req *EnrollmentR VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` var resAt sql.NullInt64 if req.ResolvedAt != nil { - resAt = sql.NullInt64{Int64: req.ResolvedAt.Unix(), Valid: true} + resAt = sql.NullInt64{Int64: req.ResolvedAt.UnixMilli(), Valid: true} } _, err = s.db.ExecContext(ctx, s.rebind(query), req.ID, @@ -1433,7 +1498,7 @@ func (s *SQLStore) CreateEnrollmentRequest(ctx context.Context, req *EnrollmentR int(req.Status), string(labelsJSON), req.BiscuitToken, - req.CreatedAt.Unix(), + req.CreatedAt.UnixMilli(), resAt, req.ResolvedBy, ) @@ -1491,10 +1556,10 @@ func (s *SQLStore) scanEnrollmentRequest(row scannable) (*EnrollmentRequest, err } } - req.CreatedAt = time.Unix(created, 0) + req.CreatedAt = time.UnixMilli(created) req.Status = api.EnrollmentStatus(statusVal) if resAt.Valid { - t := time.Unix(resAt.Int64, 0) + t := time.UnixMilli(resAt.Int64) req.ResolvedAt = &t } @@ -1539,7 +1604,7 @@ func (s *SQLStore) UpdateEnrollmentRequest(ctx context.Context, id string, statu _, err := s.db.ExecContext(ctx, s.rebind(query), int(status), biscuit, - time.Now().Unix(), + time.Now().UnixMilli(), resolvedBy, id, ) @@ -1556,7 +1621,7 @@ func (s *SQLStore) ResolveEnrollmentRequest(ctx context.Context, id string, stat res, err := s.db.ExecContext(ctx, s.rebind(query), int(status), biscuit, - time.Now().Unix(), + time.Now().UnixMilli(), resolvedBy, id, int(api.EnrollmentStatus_ENROLLMENT_STATUS_PENDING), @@ -1673,10 +1738,10 @@ func (s *SQLStore) ListBootstrapTokens(ctx context.Context) ([]BootstrapToken, e if ownerID.Valid { t.OwnerID = ownerID.String } - t.CreatedAt = time.Unix(created, 0) - t.ExpiresAt = time.Unix(expires, 0) + t.CreatedAt = time.UnixMilli(created) + t.ExpiresAt = time.UnixMilli(expires) if revokedAt.Valid { - rt := time.Unix(revokedAt.Int64, 0) + rt := time.UnixMilli(revokedAt.Int64) t.RevokedAt = &rt } tokens = append(tokens, t) @@ -1707,7 +1772,7 @@ func (s *SQLStore) SaveUser(ctx context.Context, user *User) error { user.Issuer, user.Email, user.Role, - user.CreatedAt.Unix(), + user.CreatedAt.UnixMilli(), ) return err } @@ -1730,7 +1795,7 @@ func (s *SQLStore) GetUser(ctx context.Context, id string) (*User, error) { if err != nil { return nil, err } - user.CreatedAt = time.Unix(created, 0) + user.CreatedAt = time.UnixMilli(created) return &user, nil } @@ -1756,7 +1821,7 @@ func (s *SQLStore) ListUsers(ctx context.Context) ([]User, error) { ); err != nil { return nil, err } - user.CreatedAt = time.Unix(created, 0) + user.CreatedAt = time.UnixMilli(created) users = append(users, user) } if err := rows.Err(); err != nil { diff --git a/internal/storage/sql_store_test.go b/internal/storage/sql_store_test.go index 87bd0ec4..e44f1f19 100644 --- a/internal/storage/sql_store_test.go +++ b/internal/storage/sql_store_test.go @@ -421,6 +421,48 @@ func TestIdentityBanOps(t *testing.T) { } } +// A ban names the device and the identity behind it in one transaction: an +// unknown peer rolls the identity ban back, so the two never disagree. +func TestSetNodeAndIdentityBannedIsAtomic(t *testing.T) { + store := newTestStore(t) + defer func() { _ = store.Close() }() + ctx := context.Background() + const identity = "http://issuer.example|atomic-sub" + + err := store.SetNodeAndIdentityBanned(ctx, "12D3KooWNoSuchNode", identity, true) + if err != ErrNotFound { + t.Fatalf("banning an unknown node: got %v, want ErrNotFound", err) + } + if banned, _ := store.IsIdentityBanned(ctx, identity); banned { + t.Fatal("identity ban survived the rolled-back node ban") + } + + node := &EnrolledNode{PeerID: "12D3KooWAtomicNode", PublicKey: []byte("pk"), Biscuit: []byte("b"), Role: api.RoleNode, EnrollmentType: "oidc", EnrolledAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)} + if err := store.EnrollNode(ctx, node); err != nil { + t.Fatal(err) + } + if err := store.SetNodeAndIdentityBanned(ctx, node.PeerID, identity, true); err != nil { + t.Fatalf("ban: %v", err) + } + nodeBanned, _ := store.IsNodeBanned(ctx, node.PeerID) + identityBanned, _ := store.IsIdentityBanned(ctx, identity) + if !nodeBanned || !identityBanned { + t.Fatalf("after ban: node=%v identity=%v, want both true", nodeBanned, identityBanned) + } + if err := store.SetNodeAndIdentityBanned(ctx, node.PeerID, identity, false); err != nil { + t.Fatalf("unban: %v", err) + } + nodeBanned, _ = store.IsNodeBanned(ctx, node.PeerID) + identityBanned, _ = store.IsIdentityBanned(ctx, identity) + if nodeBanned || identityBanned { + t.Fatalf("after unban: node=%v identity=%v, want both false", nodeBanned, identityBanned) + } + // A bootstrap-enrolled node has no identity to ban. + if err := store.SetNodeAndIdentityBanned(ctx, node.PeerID, "", true); err != nil { + t.Fatalf("ban without identity: %v", err) + } +} + func TestRouterLeaseOps(t *testing.T) { store := newTestStore(t) defer func() { _ = store.Close() }() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 0221fdec..aaae9290 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -231,6 +231,12 @@ type Store interface { // this is what makes a ban survive keypair regeneration. SetIdentityBanned(ctx context.Context, identity string, banned bool) error + // SetNodeAndIdentityBanned is SetNodeBanned and SetIdentityBanned in one + // transaction, so a failure cannot leave the device banned while its + // identity may still enroll a new one, or the other way round. An empty + // identity bans the node alone. + SetNodeAndIdentityBanned(ctx context.Context, peerID, identity string, banned bool) error + // IsIdentityBanned checks if an identity is currently banned. IsIdentityBanned(ctx context.Context, identity string) (bool, error) diff --git a/sdk/js/src/authorizer.test.ts b/sdk/js/src/authorizer.test.ts index 87308dac..b77b4251 100644 --- a/sdk/js/src/authorizer.test.ts +++ b/sdk/js/src/authorizer.test.ts @@ -23,6 +23,7 @@ import { before, test } from "node:test"; import { AuthorizationError, authorizeCaller, type AuthorizeRequest } from "./authorizer.ts"; import { BiscuitVerificationError, attenuateBiscuit, loadBiscuit, sealBiscuit } from "./biscuit.ts"; import { BASELINE_DATALOG } from "./gen/datalog.ts"; +import { encodeTARBlockFact } from "./tar.ts"; type Wasm = Awaited>; @@ -284,3 +285,42 @@ test("attenuateBiscuit and sealBiscuit narrow authority across hops", async () = ); }); +// An appended block may carry exactly one tar_block fact. Anything else a +// holder writes there (a rule, a check, a second fact) is refused before the +// Datalog runs, whatever the authority block grants. +test("appended blocks carrying anything but one tar_block fact are refused", async () => { + const root = nodeToken(CALLER, [`granted_service_all_types(true)`, `target_unrestricted(true)`]); + const tarFact = encodeTARBlockFact({ name: "hop", rules: [{ allowedServices: ["mcp://calc"] }] }); + const append = (build: (block: ReturnType) => void): Uint8Array => { + const token = wasm.Biscuit.fromBytes(root, cpKeyPair.getPublicKey()); + const block = wasm.Biscuit.block_builder(); + build(block); + return token.appendBlock(block).toBytes(); + }; + + const malformed: Record = { + rule: append((b) => b.addRule(wasm.Rule.fromString(`role("sam:role:router") <- role("sam:role:node")`))), + check: append((b) => b.addCheck(wasm.Check.fromString(`check if true`))), + "extra fact": append((b) => { + b.addFact(wasm.Fact.fromString(tarFact)); + b.addFact(wasm.Fact.fromString(`node(${JSON.stringify(CALLER)})`)); + }), + "rule beside tar_block": append((b) => { + b.addFact(wasm.Fact.fromString(tarFact)); + b.addRule(wasm.Rule.fromString(`granted_service_all_types(true) <- role("sam:role:node")`)); + }), + }; + for (const [name, token] of Object.entries(malformed)) { + await assert.rejects( + authorizeCaller(request(token, "mcp://calc"), options([])), + (err: unknown) => err instanceof AuthorizationError && /tar_block/.test(err.message), + name, + ); + } + + // The well-formed block is the control. + const ok = append((b) => b.addFact(wasm.Fact.fromString(tarFact))); + const verified = await authorizeCaller(request(ok, "mcp://calc"), options([])); + assert.equal(verified.taskRules.length, 1); +}); + diff --git a/sdk/js/src/gen/sam_pb.ts b/sdk/js/src/gen/sam_pb.ts index be7dea1e..93e02c31 100644 --- a/sdk/js/src/gen/sam_pb.ts +++ b/sdk/js/src/gen/sam_pb.ts @@ -26,7 +26,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file sam.proto. */ export const file_sam: GenFile = /*@__PURE__*/ - fileDesc("CglzYW0ucHJvdG8SBnNhbS52MSI0CglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMItYBCglNZXNoRXZlbnQSJAoEdHlwZRgBIAEoDjIWLnNhbS52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiLzAQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjEKBmxhYmVscxgFIAMoCzIhLnNhbS52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKRAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjoKBmxhYmVscxgFIAMoCzIqLnNhbS52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASL9AQoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USKAoGc3RhdHVzGAEgASgOMhguc2FtLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiUwoLU2VydmljZUluZm8SIQoEdHlwZRgBIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJInsKDkNvbW1hbmRCYWNrZW5kEg8KB2NvbW1hbmQYASADKAkSLAoDZW52GAIgAygLMh8uc2FtLnYxLkNvbW1hbmRCYWNrZW5kLkVudkVudHJ5GioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiigEKFlJlZ2lzdGVyU2VydmljZVJlcXVlc3QSJAoHc2VydmljZRgBIAEoCzITLnNhbS52MS5TZXJ2aWNlSW5mbxIUCgp0YXJnZXRfdXJsGAIgASgJSAASKQoHY29tbWFuZBgDIAEoCzIWLnNhbS52MS5Db21tYW5kQmFja2VuZEgAQgkKB2JhY2tlbmQiaQoSRGlzY292ZXJlZFByb3ZpZGVyEg8KB3BlZXJfaWQYASABKAkSFwoPbG9jYWxfcHJveHlfdXJsGAIgASgJEhAKCHNydl9uYW1lGAMgASgJEhcKD3Nydl9kZXNjcmlwdGlvbhgEIAEoCSKyAgoPU2VydmljZUFubm91bmNlEg8KB3BlZXJfaWQYASABKAkSIQoEdHlwZRgCIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIUCgxzZXJ2aWNlX25hbWUYAyABKAkSDAoEa2V5cxgEIAMoCRIzCgZsYWJlbHMYBSADKAsyIy5zYW0udjEuU2VydmljZUFubm91bmNlLkxhYmVsc0VudHJ5EhcKD2FjdGl2ZV9yZXF1ZXN0cxgGIAEoDRIXCg9sYXRlbmN5X2V3bWFfbXMYByABKAESMQoNYW5ub3VuY2VfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKHAQoYQ29udHJvbFBsYW5lSW5mb1Jlc3BvbnNlEhMKC29pZGNfaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgFIAMoCSKsAQoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKeAQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAUgAygJEh8KBGh0dHAYBiADKAsyES5zYW0udjEuSFRUUEdyYW50IjwKCUhUVFBHcmFudBIPCgdzZXJ2aWNlGAEgASgJEg8KB21ldGhvZHMYAiADKAkSDQoFcGF0aHMYAyADKAkijwIKEUVncmVzc0Rlc3RpbmF0aW9uEgwKBG5hbWUYASABKAkSEgoKdGFyZ2V0X3VybBgCIAEoCRISCgpjcmVkZW50aWFsGAMgASgJEhEKCXNlcnZlZF9ieRgEIAMoCRIoCgZicm9rZXIYBSABKAsyGC5zYW0udjEuQ3JlZGVudGlhbEJyb2tlchImCgppbnNwZWN0aW9uGAYgASgLMhIuc2FtLnYxLkluc3BlY3Rpb24SIAoEbW9kZRgHIAEoDjISLnNhbS52MS5FZ3Jlc3NNb2RlEg0KBXBvcnRzGAggAygNEhUKDXByZXNlcnZlX2hvc3QYCSABKAgSFwoPZm9yd2FyZF9jb250ZXh0GAogASgIIjMKCkluc3BlY3Rpb24SJQoKaW5zcGVjdG9ycxgBIAMoCzIRLnNhbS52MS5JbnNwZWN0b3IiYwoJSW5zcGVjdG9yEikKC21vZGVsX2FybW9yGAEgASgLMhIuc2FtLnYxLk1vZGVsQXJtb3JIABIjCghleHRfcHJvYxgCIAEoCzIPLnNhbS52MS5FeHRQcm9jSABCBgoEa2luZCKLAQoKTW9kZWxBcm1vchIQCgh0ZW1wbGF0ZRgBIAEoCRIsCghyZXNwb25zZRgCIAEoDjIaLnNhbS52MS5SZXNwb25zZUluc3BlY3Rpb24SEQoJZmFpbF9vcGVuGAMgASgIEioKB3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24iggIKB0V4dFByb2MSDgoGdGFyZ2V0GAEgASgJEgoKAmNhGAIgASgJEhoKEmNsaWVudF9jZXJ0aWZpY2F0ZRgDIAEoCRI2Cg9wcm9jZXNzaW5nX21vZGUYBCABKAsyHS5zYW0udjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlEhsKE2FsbG93X21vZGVfb3ZlcnJpZGUYBSABKAgSMgoPbWVzc2FnZV90aW1lb3V0GAYgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEhoKEmZhaWx1cmVfbW9kZV9hbGxvdxgHIAEoCBIaChJtYXhfYnVmZmVyZWRfYnl0ZXMYCCABKA0i2wQKFUV4dFByb2NQcm9jZXNzaW5nTW9kZRJFChNyZXF1ZXN0X2hlYWRlcl9tb2RlGAEgASgOMiguc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkYKFHJlc3BvbnNlX2hlYWRlcl9tb2RlGAIgASgOMiguc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkEKEXJlcXVlc3RfYm9keV9tb2RlGAMgASgOMiYuc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5Cb2R5TW9kZRJCChJyZXNwb25zZV9ib2R5X21vZGUYBCABKA4yJi5zYW0udjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlLkJvZHlNb2RlEkYKFHJlcXVlc3RfdHJhaWxlcl9tb2RlGAUgASgOMiguc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkcKFXJlc3BvbnNlX3RyYWlsZXJfbW9kZRgGIAEoDjIoLnNhbS52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZSI5CgpIZWFkZXJNb2RlEhcKE0hFQURFUl9NT0RFX0RFRkFVTFQQABIICgRTRU5EEAESCAoEU0tJUBACImAKCEJvZHlNb2RlEggKBE5PTkUQABIMCghTVFJFQU1FRBABEgwKCEJVRkZFUkVEEAISFAoQQlVGRkVSRURfUEFSVElBTBADEhgKFEZVTExfRFVQTEVYX1NUUkVBTUVEEAQizwEKEENyZWRlbnRpYWxCcm9rZXISFwoNc3RhdGljX3NlY3JldBgBIAEoCUgAEjEKD29pZGNfZmVkZXJhdGlvbhgCIAEoCzIWLnNhbS52MS5PSURDRmVkZXJhdGlvbkgAEjAKD2F3c19hc3N1bWVfcm9sZRgDIAEoCzIVLnNhbS52MS5BV1NBc3N1bWVSb2xlSAASNQoRcGxhdGZvcm1faWRlbnRpdHkYBCABKAsyGC5zYW0udjEuUGxhdGZvcm1JZGVudGl0eUgAQgYKBGtpbmQiXwoOT0lEQ0ZlZGVyYXRpb24SFgoOdG9rZW5fZW5kcG9pbnQYASABKAkSEAoIYXVkaWVuY2UYAiABKAkSEwoLaW1wZXJzb25hdGUYAyABKAkSDgoGc2NvcGVzGAQgAygJIjkKDUFXU0Fzc3VtZVJvbGUSEAoIcm9sZV9hcm4YASABKAkSFgoOc2Vzc2lvbl9wb2xpY3kYAiABKAkiIgoQUGxhdGZvcm1JZGVudGl0eRIOCgZzY29wZXMYASADKAkiLgoNUG9saWN5QmluZGluZxIMCgRyb2xlGAEgASgJEg8KB21lbWJlcnMYAiADKAkihQEKDFBvbGljeUNvbmZpZxIhCgVyb2xlcxgBIAMoCzISLnNhbS52MS5Qb2xpY3lSb2xlEicKCGJpbmRpbmdzGAIgAygLMhUuc2FtLnYxLlBvbGljeUJpbmRpbmcSKQoGZWdyZXNzGAMgAygLMhkuc2FtLnYxLkVncmVzc0Rlc3RpbmF0aW9uIhgKFlBvbGljeUNvbmZpZ0dldFJlcXVlc3QiMAoXUG9saWN5Q29uZmlnR2V0UmVzcG9uc2USFQoNZGF0YWxvZ19ydWxlcxgBIAMoCSI8ChpQb2xpY3lDb25maWdVcGRhdGVSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIhoKGEVncmVzc0Fzc2lnbm1lbnRzUmVxdWVzdCJGChlFZ3Jlc3NBc3NpZ25tZW50c1Jlc3BvbnNlEikKBmVncmVzcxgBIAMoCzIZLnNhbS52MS5FZ3Jlc3NEZXN0aW5hdGlvbiJmCgxLZXlzUmVzcG9uc2USEwoLcHVibGljX2tleXMYASADKAwSLQoJc2lnbl90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpzaWduYXR1cmVzGAMgAygMImsKE1Rva2VuUmVmcmVzaFJlcXVlc3QSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgBIAEoDBIZChFjaGFsbGVuZ2VfdW5peF9tcxgCIAEoAxIPCgdwZWVyX2lkGAMgASgJEgsKA2p3dBgEIAEoCSJ1ChRUb2tlblJlZnJlc2hSZXNwb25zZRIVCg1iaXNjdWl0X3Rva2VuGAEgASgMEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIjoKEU5vZGVDYXRhbG9nUmVwb3J0EiUKCHNlcnZpY2VzGAEgAygLMhMuc2FtLnYxLlNlcnZpY2VJbmZvIiUKElRva2VuUmV2b2tlUmVxdWVzdBIPCgdwZWVyX2lkGAEgASgJIjUKE1Rva2VuUmV2b2tlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSLkAQoYSWRlbnRpdHlFdmlkZW5jZVJlc3BvbnNlEg8KB3BlZXJfaWQYASABKAkSDwoHYmlzY3VpdBgCIAEoDBI3ChNiaXNjdWl0X2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIZChFjb250cm9sX3BsYW5lX3VybBgEIAEoCRIiChp0cnVzdGVkX2NvbnRyb2xfcGxhbmVfa2V5cxgFIAMoDBIuCgpjaGVja190aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLAAgoUUGVlckV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEhUKDXZlcmlmeWluZ19rZXkYAyABKAwSDQoFcm9sZXMYBCADKAkSOAoGbGFiZWxzGAUgAygLMiguc2FtLnYxLlBlZXJFdmlkZW5jZVJlc3BvbnNlLkxhYmVsc0VudHJ5Ei8KC2V4cGlyZV90aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIWCg5yZXZvY2F0aW9uX2lkcxgHIAMoCRIuCgpjaGVja190aW1lGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBotCgtMYWJlbHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIoACChBNZW1iZXJDcmVkZW50aWFsEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSLwoLZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KDHRydXN0ZWRfa2V5cxgEIAMoCzIZLnNhbS52MS5UcnVzdGVkU2lnbmluZ0tleRIZChFpc3N1ZWRfdW5kZXJfa2V5cxgFIAMoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAYgAygJEikKDG9pZGNfc2Vzc2lvbhgHIAEoCzITLnNhbS52MS5PSURDU2Vzc2lvbiJZChFUcnVzdGVkU2lnbmluZ0tleRISCgpwdWJsaWNfa2V5GAEgASgMEjAKDHJlY2VpdmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWQoLT0lEQ1Nlc3Npb24SDgoGaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAQgASgJIo0BChVUYXNrQXV0aG9yaXphdGlvblJ1bGUSDAoEbmFtZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSHwoFcnVsZXMYAyADKAsyEC5zYW0udjEuVGFza1J1bGUSLwoLZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIn4KCFRhc2tSdWxlEhMKC2Rlc2NyaXB0aW9uGAEgASgJEhgKEGFsbG93ZWRfc2VydmljZXMYAiADKAkSKAoJb3BlcmF0aW9uGAMgASgLMhUuc2FtLnYxLlRhc2tPcGVyYXRpb24SGQoRYWxsb3dlZF9yZXNvdXJjZXMYBCADKAkicwoNVGFza09wZXJhdGlvbhIVCg1hbGxvd2VkX3Rvb2xzGAEgAygJEhcKD2FsbG93ZWRfbWV0aG9kcxgCIAMoCRIVCg1hbGxvd2VkX3BhdGhzGAMgAygJEhsKE2FsbG93ZWRfcGVybWlzc2lvbnMYBCADKAkipQEKFFRva2VuRXhjaGFuZ2VSZXF1ZXN0EhUKDXN1YmplY3RfdG9rZW4YASABKAkSMAoJdGFza19ydWxlGAIgASgLMh0uc2FtLnYxLlRhc2tBdXRob3JpemF0aW9uUnVsZRIMCgRzZWFsGAMgASgIEhkKEWNoYWxsZW5nZV91bml4X21zGAQgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYBSABKAwifwoVVG9rZW5FeGNoYW5nZVJlc3BvbnNlEhUKDWJpc2N1aXRfdG9rZW4YASABKAwSLwoLZXhwaXJlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg0KBXJvbGVzGAMgAygJEg8KB3N1YmplY3QYBCABKAkigQEKD1NUU1Rva2VuUmVxdWVzdBIPCgdiaXNjdWl0GAEgASgMEhMKC2Rlc3RpbmF0aW9uGAIgASgJEhAKCGF1ZGllbmNlGAMgASgJEhkKEWNoYWxsZW5nZV91bml4X21zGAQgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYBSABKAwigwEKEFNUU1Rva2VuUmVzcG9uc2USCwoDand0GAEgASgJEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdzdWJqZWN0GAMgASgJEg0KBXJvbGVzGAQgAygJEhEKCXRhc2tfbmFtZRgFIAEoCSJGChNSZXZvY2F0aW9uc1Jlc3BvbnNlEhYKDnJldm9jYXRpb25faWRzGAEgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgCIAMoCSqUAQoQRW5yb2xsbWVudFN0YXR1cxIhCh1FTlJPTExNRU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEh0KGUVOUk9MTE1FTlRfU1RBVFVTX1BFTkRJTkcQARIeChpFTlJPTExNRU5UX1NUQVRVU19BUFBST1ZFRBACEh4KGkVOUk9MTE1FTlRfU1RBVFVTX1JFSkVDVEVEEAMqjAEKC1NlcnZpY2VUeXBlEhwKGFNFUlZJQ0VfVFlQRV9VTlNQRUNJRklFRBAAEhQKEFNFUlZJQ0VfVFlQRV9NQ1AQARIaChZTRVJWSUNFX1RZUEVfSU5GRVJFTkNFEAISFAoQU0VSVklDRV9UWVBFX0EyQRADEhcKE1NFUlZJQ0VfVFlQRV9FR1JFU1MQBCo3CgpFZ3Jlc3NNb2RlEhQKEEVHUkVTU19NT0RFX0hUVFAQABITCg9FR1JFU1NfTU9ERV9UQ1AQASpcChJSZXNwb25zZUluc3BlY3Rpb24SIAocUkVTUE9OU0VfSU5TUEVDVElPTl9CVUZGRVJFRBAAEiQKIFJFU1BPTlNFX0lOU1BFQ1RJT05fUkVRVUVTVF9PTkxZEAFCG1oZZ2l0aHViLmNvbS9nb29nbGUvc2FtL2FwaWIGcHJvdG8z", [file_google_protobuf_duration, file_google_protobuf_timestamp]); + fileDesc("CglzYW0ucHJvdG8SBnNhbS52MSI0CglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMItYBCglNZXNoRXZlbnQSJAoEdHlwZRgBIAEoDjIWLnNhbS52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiLzAQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjEKBmxhYmVscxgFIAMoCzIhLnNhbS52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKRAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjoKBmxhYmVscxgFIAMoCzIqLnNhbS52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASL9AQoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USKAoGc3RhdHVzGAEgASgOMhguc2FtLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiUwoLU2VydmljZUluZm8SIQoEdHlwZRgBIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJInsKDkNvbW1hbmRCYWNrZW5kEg8KB2NvbW1hbmQYASADKAkSLAoDZW52GAIgAygLMh8uc2FtLnYxLkNvbW1hbmRCYWNrZW5kLkVudkVudHJ5GioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiigEKFlJlZ2lzdGVyU2VydmljZVJlcXVlc3QSJAoHc2VydmljZRgBIAEoCzITLnNhbS52MS5TZXJ2aWNlSW5mbxIUCgp0YXJnZXRfdXJsGAIgASgJSAASKQoHY29tbWFuZBgDIAEoCzIWLnNhbS52MS5Db21tYW5kQmFja2VuZEgAQgkKB2JhY2tlbmQiaQoSRGlzY292ZXJlZFByb3ZpZGVyEg8KB3BlZXJfaWQYASABKAkSFwoPbG9jYWxfcHJveHlfdXJsGAIgASgJEhAKCHNydl9uYW1lGAMgASgJEhcKD3Nydl9kZXNjcmlwdGlvbhgEIAEoCSKyAgoPU2VydmljZUFubm91bmNlEg8KB3BlZXJfaWQYASABKAkSIQoEdHlwZRgCIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIUCgxzZXJ2aWNlX25hbWUYAyABKAkSDAoEa2V5cxgEIAMoCRIzCgZsYWJlbHMYBSADKAsyIy5zYW0udjEuU2VydmljZUFubm91bmNlLkxhYmVsc0VudHJ5EhcKD2FjdGl2ZV9yZXF1ZXN0cxgGIAEoDRIXCg9sYXRlbmN5X2V3bWFfbXMYByABKAESMQoNYW5ub3VuY2VfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKHAQoYQ29udHJvbFBsYW5lSW5mb1Jlc3BvbnNlEhMKC29pZGNfaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgFIAMoCSKsAQoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKeAQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAUgAygJEh8KBGh0dHAYBiADKAsyES5zYW0udjEuSFRUUEdyYW50IjwKCUhUVFBHcmFudBIPCgdzZXJ2aWNlGAEgASgJEg8KB21ldGhvZHMYAiADKAkSDQoFcGF0aHMYAyADKAkijwIKEUVncmVzc0Rlc3RpbmF0aW9uEgwKBG5hbWUYASABKAkSEgoKdGFyZ2V0X3VybBgCIAEoCRISCgpjcmVkZW50aWFsGAMgASgJEhEKCXNlcnZlZF9ieRgEIAMoCRIoCgZicm9rZXIYBSABKAsyGC5zYW0udjEuQ3JlZGVudGlhbEJyb2tlchImCgppbnNwZWN0aW9uGAYgASgLMhIuc2FtLnYxLkluc3BlY3Rpb24SIAoEbW9kZRgHIAEoDjISLnNhbS52MS5FZ3Jlc3NNb2RlEg0KBXBvcnRzGAggAygNEhUKDXByZXNlcnZlX2hvc3QYCSABKAgSFwoPZm9yd2FyZF9jb250ZXh0GAogASgIIjMKCkluc3BlY3Rpb24SJQoKaW5zcGVjdG9ycxgBIAMoCzIRLnNhbS52MS5JbnNwZWN0b3IiYwoJSW5zcGVjdG9yEikKC21vZGVsX2FybW9yGAEgASgLMhIuc2FtLnYxLk1vZGVsQXJtb3JIABIjCghleHRfcHJvYxgCIAEoCzIPLnNhbS52MS5FeHRQcm9jSABCBgoEa2luZCKLAQoKTW9kZWxBcm1vchIQCgh0ZW1wbGF0ZRgBIAEoCRIsCghyZXNwb25zZRgCIAEoDjIaLnNhbS52MS5SZXNwb25zZUluc3BlY3Rpb24SEQoJZmFpbF9vcGVuGAMgASgIEioKB3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24iggIKB0V4dFByb2MSDgoGdGFyZ2V0GAEgASgJEgoKAmNhGAIgASgJEhoKEmNsaWVudF9jZXJ0aWZpY2F0ZRgDIAEoCRI2Cg9wcm9jZXNzaW5nX21vZGUYBCABKAsyHS5zYW0udjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlEhsKE2FsbG93X21vZGVfb3ZlcnJpZGUYBSABKAgSMgoPbWVzc2FnZV90aW1lb3V0GAYgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEhoKEmZhaWx1cmVfbW9kZV9hbGxvdxgHIAEoCBIaChJtYXhfYnVmZmVyZWRfYnl0ZXMYCCABKA0i2wQKFUV4dFByb2NQcm9jZXNzaW5nTW9kZRJFChNyZXF1ZXN0X2hlYWRlcl9tb2RlGAEgASgOMiguc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkYKFHJlc3BvbnNlX2hlYWRlcl9tb2RlGAIgASgOMiguc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkEKEXJlcXVlc3RfYm9keV9tb2RlGAMgASgOMiYuc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5Cb2R5TW9kZRJCChJyZXNwb25zZV9ib2R5X21vZGUYBCABKA4yJi5zYW0udjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlLkJvZHlNb2RlEkYKFHJlcXVlc3RfdHJhaWxlcl9tb2RlGAUgASgOMiguc2FtLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkcKFXJlc3BvbnNlX3RyYWlsZXJfbW9kZRgGIAEoDjIoLnNhbS52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZSI5CgpIZWFkZXJNb2RlEhcKE0hFQURFUl9NT0RFX0RFRkFVTFQQABIICgRTRU5EEAESCAoEU0tJUBACImAKCEJvZHlNb2RlEggKBE5PTkUQABIMCghTVFJFQU1FRBABEgwKCEJVRkZFUkVEEAISFAoQQlVGRkVSRURfUEFSVElBTBADEhgKFEZVTExfRFVQTEVYX1NUUkVBTUVEEAQizwEKEENyZWRlbnRpYWxCcm9rZXISFwoNc3RhdGljX3NlY3JldBgBIAEoCUgAEjEKD29pZGNfZmVkZXJhdGlvbhgCIAEoCzIWLnNhbS52MS5PSURDRmVkZXJhdGlvbkgAEjAKD2F3c19hc3N1bWVfcm9sZRgDIAEoCzIVLnNhbS52MS5BV1NBc3N1bWVSb2xlSAASNQoRcGxhdGZvcm1faWRlbnRpdHkYBCABKAsyGC5zYW0udjEuUGxhdGZvcm1JZGVudGl0eUgAQgYKBGtpbmQiXwoOT0lEQ0ZlZGVyYXRpb24SFgoOdG9rZW5fZW5kcG9pbnQYASABKAkSEAoIYXVkaWVuY2UYAiABKAkSEwoLaW1wZXJzb25hdGUYAyABKAkSDgoGc2NvcGVzGAQgAygJIjkKDUFXU0Fzc3VtZVJvbGUSEAoIcm9sZV9hcm4YASABKAkSFgoOc2Vzc2lvbl9wb2xpY3kYAiABKAkiIgoQUGxhdGZvcm1JZGVudGl0eRIOCgZzY29wZXMYASADKAkiLgoNUG9saWN5QmluZGluZxIMCgRyb2xlGAEgASgJEg8KB21lbWJlcnMYAiADKAkihQEKDFBvbGljeUNvbmZpZxIhCgVyb2xlcxgBIAMoCzISLnNhbS52MS5Qb2xpY3lSb2xlEicKCGJpbmRpbmdzGAIgAygLMhUuc2FtLnYxLlBvbGljeUJpbmRpbmcSKQoGZWdyZXNzGAMgAygLMhkuc2FtLnYxLkVncmVzc0Rlc3RpbmF0aW9uIhgKFlBvbGljeUNvbmZpZ0dldFJlcXVlc3QiMAoXUG9saWN5Q29uZmlnR2V0UmVzcG9uc2USFQoNZGF0YWxvZ19ydWxlcxgBIAMoCSI8ChpQb2xpY3lDb25maWdVcGRhdGVSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIhoKGEVncmVzc0Fzc2lnbm1lbnRzUmVxdWVzdCJGChlFZ3Jlc3NBc3NpZ25tZW50c1Jlc3BvbnNlEikKBmVncmVzcxgBIAMoCzIZLnNhbS52MS5FZ3Jlc3NEZXN0aW5hdGlvbiJmCgxLZXlzUmVzcG9uc2USEwoLcHVibGljX2tleXMYASADKAwSLQoJc2lnbl90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpzaWduYXR1cmVzGAMgAygMImsKE1Rva2VuUmVmcmVzaFJlcXVlc3QSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgBIAEoDBIZChFjaGFsbGVuZ2VfdW5peF9tcxgCIAEoAxIPCgdwZWVyX2lkGAMgASgJEgsKA2p3dBgEIAEoCSJ1ChRUb2tlblJlZnJlc2hSZXNwb25zZRIVCg1iaXNjdWl0X3Rva2VuGAEgASgMEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIjoKEU5vZGVDYXRhbG9nUmVwb3J0EiUKCHNlcnZpY2VzGAEgAygLMhMuc2FtLnYxLlNlcnZpY2VJbmZvIiUKElRva2VuUmV2b2tlUmVxdWVzdBIPCgdwZWVyX2lkGAEgASgJIjUKE1Rva2VuUmV2b2tlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSKWAQobQm9vdHN0cmFwVG9rZW5DcmVhdGVSZXF1ZXN0EgwKBHJvbGUYASABKAkSEAoIb3duZXJfaWQYAiABKAkSEQoJdHRsX2hvdXJzGAMgASgFEhIKCm1heF91c2FnZXMYBCABKAUSEwoLZGVzY3JpcHRpb24YBSABKAkSGwoTYXV0b25vbW91c19yZWNvdmVyeRgGIAEoCCKKAQocQm9vdHN0cmFwVG9rZW5DcmVhdGVSZXNwb25zZRIKCgJpZBgBIAEoCRINCgV0b2tlbhgCIAEoCRIMCgRyb2xlGAMgASgJEhAKCG93bmVyX2lkGAQgASgJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKrAgoOQm9vdHN0cmFwVG9rZW4SCgoCaWQYASABKAkSDAoEcm9sZRgCIAEoCRIQCghvd25lcl9pZBgDIAEoCRISCgptYXhfdXNhZ2VzGAQgASgFEhQKDHVzYWdlc19jb3VudBgFIAEoBRITCgtkZXNjcmlwdGlvbhgGIAEoCRIvCgtjcmVhdGVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC3Jldm9rZV90aW1lGAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIbChNhdXRvbm9tb3VzX3JlY292ZXJ5GAogASgIIkQKGkJvb3RzdHJhcFRva2VuTGlzdFJlc3BvbnNlEiYKBnRva2VucxgBIAMoCzIWLnNhbS52MS5Cb290c3RyYXBUb2tlbiLKAgoRRW5yb2xsbWVudFJlcXVlc3QSCgoCaWQYASABKAkSDwoHcGVlcl9pZBgCIAEoCRIQCgh0b2tlbl9pZBgDIAEoCRIoCgZzdGF0dXMYBCABKA4yGC5zYW0udjEuRW5yb2xsbWVudFN0YXR1cxI1CgZsYWJlbHMYBSADKAsyJS5zYW0udjEuRW5yb2xsbWVudFJlcXVlc3QuTGFiZWxzRW50cnkSLwoLY3JlYXRlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDHJlc29sdmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEwoLcmVzb2x2ZWRfYnkYCCABKAkaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJMCh1FbnJvbGxtZW50UmVxdWVzdExpc3RSZXNwb25zZRIrCghyZXF1ZXN0cxgBIAMoCzIZLnNhbS52MS5FbnJvbGxtZW50UmVxdWVzdCJwCgRVc2VyEgoKAmlkGAEgASgJEg4KBmlzc3VlchgCIAEoCRINCgVlbWFpbBgDIAEoCRIMCgRyb2xlGAQgASgJEi8KC2NyZWF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLdAgoMRW5yb2xsZWROb2RlEg8KB3BlZXJfaWQYASABKAkSDAoEcm9sZRgCIAEoCRIXCg9lbnJvbGxtZW50X3R5cGUYAyABKAkSEwoLY2xhaW1zX2pzb24YBCABKAkSEAoIb3duZXJfaWQYBSABKAkSMAoGbGFiZWxzGAYgAygLMiAuc2FtLnYxLkVucm9sbGVkTm9kZS5MYWJlbHNFbnRyeRIvCgtlbnJvbGxfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg4KBmJhbm5lZBgJIAEoCBIbChNhdXRvbm9tb3VzX3JlY292ZXJ5GAogASgIGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEixAEKC1JvdXRlckxlYXNlEg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEjUKEWxhc3RfcmVuZXdhbF90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtleHBpcmVfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFwoPY29ubmVjdGVkX3BlZXJzGAUgAygJEhAKCGRodF9zaXplGAYgASgFImYKDE5vZGVTZXJ2aWNlcxIlCghzZXJ2aWNlcxgBIAMoCzITLnNhbS52MS5TZXJ2aWNlSW5mbxIvCgtyZXBvcnRfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiqwMKE0FkbWluU3RhdHVzUmVzcG9uc2USGwoFdXNlcnMYASADKAsyDC5zYW0udjEuVXNlchIrCg5hY3RpdmVfcm91dGVycxgCIAMoCzITLnNhbS52MS5Sb3V0ZXJMZWFzZRIsCg5lbnJvbGxlZF9ub2RlcxgDIAMoCzIULnNhbS52MS5FbnJvbGxlZE5vZGUSNgoTZW5yb2xsbWVudF9yZXF1ZXN0cxgEIAMoCzIZLnNhbS52MS5FbnJvbGxtZW50UmVxdWVzdBIwChBib290c3RyYXBfdG9rZW5zGAUgAygLMhYuc2FtLnYxLkJvb3RzdHJhcFRva2VuEiQKBnBvbGljeRgGIAEoCzIULnNhbS52MS5Qb2xpY3lDb25maWcSQgoMbm9kZV9jYXRhbG9nGAcgAygLMiwuc2FtLnYxLkFkbWluU3RhdHVzUmVzcG9uc2UuTm9kZUNhdGFsb2dFbnRyeRpIChBOb2RlQ2F0YWxvZ0VudHJ5EgsKA2tleRgBIAEoCRIjCgV2YWx1ZRgCIAEoCzIULnNhbS52MS5Ob2RlU2VydmljZXM6AjgBIuMBChJVc2VyU3RhdHVzUmVzcG9uc2USGgoEdXNlchgBIAEoCzIMLnNhbS52MS5Vc2VyEiwKDmVucm9sbGVkX25vZGVzGAIgAygLMhQuc2FtLnYxLkVucm9sbGVkTm9kZRIwChBib290c3RyYXBfdG9rZW5zGAMgAygLMhYuc2FtLnYxLkJvb3RzdHJhcFRva2VuEisKDmFjdGl2ZV9yb3V0ZXJzGAQgAygLMhMuc2FtLnYxLlJvdXRlckxlYXNlEiQKBnBvbGljeRgFIAEoCzIULnNhbS52MS5Qb2xpY3lDb25maWci5AEKGElkZW50aXR5RXZpZGVuY2VSZXNwb25zZRIPCgdwZWVyX2lkGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSNwoTYmlzY3VpdF9leHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoRY29udHJvbF9wbGFuZV91cmwYBCABKAkSIgoadHJ1c3RlZF9jb250cm9sX3BsYW5lX2tleXMYBSADKAwSLgoKY2hlY2tfdGltZRgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiwAIKFFBlZXJFdmlkZW5jZVJlc3BvbnNlEg8KB3BlZXJfaWQYASABKAkSDwoHYmlzY3VpdBgCIAEoDBIVCg12ZXJpZnlpbmdfa2V5GAMgASgMEg0KBXJvbGVzGAQgAygJEjgKBmxhYmVscxgFIAMoCzIoLnNhbS52MS5QZWVyRXZpZGVuY2VSZXNwb25zZS5MYWJlbHNFbnRyeRIvCgtleHBpcmVfdGltZRgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFgoOcmV2b2NhdGlvbl9pZHMYByADKAkSLgoKY2hlY2tfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKAAgoQTWVtYmVyQ3JlZGVudGlhbBIZChFjb250cm9sX3BsYW5lX3VybBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgx0cnVzdGVkX2tleXMYBCADKAsyGS5zYW0udjEuVHJ1c3RlZFNpZ25pbmdLZXkSGQoRaXNzdWVkX3VuZGVyX2tleXMYBSADKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIpCgxvaWRjX3Nlc3Npb24YByABKAsyEy5zYW0udjEuT0lEQ1Nlc3Npb24iWQoRVHJ1c3RlZFNpZ25pbmdLZXkSEgoKcHVibGljX2tleRgBIAEoDBIwCgxyZWNlaXZlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIlkKC09JRENTZXNzaW9uEg4KBmlzc3VlchgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSEAoIYXVkaWVuY2UYAyABKAkSFQoNcmVmcmVzaF90b2tlbhgEIAEoCSKNAQoVVGFza0F1dGhvcml6YXRpb25SdWxlEgwKBG5hbWUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEh8KBXJ1bGVzGAMgAygLMhAuc2FtLnYxLlRhc2tSdWxlEi8KC2V4cGlyZV90aW1lGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJ+CghUYXNrUnVsZRITCgtkZXNjcmlwdGlvbhgBIAEoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAIgAygJEigKCW9wZXJhdGlvbhgDIAEoCzIVLnNhbS52MS5UYXNrT3BlcmF0aW9uEhkKEWFsbG93ZWRfcmVzb3VyY2VzGAQgAygJInMKDVRhc2tPcGVyYXRpb24SFQoNYWxsb3dlZF90b29scxgBIAMoCRIXCg9hbGxvd2VkX21ldGhvZHMYAiADKAkSFQoNYWxsb3dlZF9wYXRocxgDIAMoCRIbChNhbGxvd2VkX3Blcm1pc3Npb25zGAQgAygJIqUBChRUb2tlbkV4Y2hhbmdlUmVxdWVzdBIVCg1zdWJqZWN0X3Rva2VuGAEgASgJEjAKCXRhc2tfcnVsZRgCIAEoCzIdLnNhbS52MS5UYXNrQXV0aG9yaXphdGlvblJ1bGUSDAoEc2VhbBgDIAEoCBIZChFjaGFsbGVuZ2VfdW5peF9tcxgEIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAUgASgMIn8KFVRva2VuRXhjaGFuZ2VSZXNwb25zZRIVCg1iaXNjdWl0X3Rva2VuGAEgASgMEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBINCgVyb2xlcxgDIAMoCRIPCgdzdWJqZWN0GAQgASgJIoEBCg9TVFNUb2tlblJlcXVlc3QSDwoHYmlzY3VpdBgBIAEoDBITCgtkZXN0aW5hdGlvbhgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIZChFjaGFsbGVuZ2VfdW5peF9tcxgEIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAUgASgMIoMBChBTVFNUb2tlblJlc3BvbnNlEgsKA2p3dBgBIAEoCRIvCgtleHBpcmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDwoHc3ViamVjdBgDIAEoCRINCgVyb2xlcxgEIAMoCRIRCgl0YXNrX25hbWUYBSABKAkiRgoTUmV2b2NhdGlvbnNSZXNwb25zZRIWCg5yZXZvY2F0aW9uX2lkcxgBIAMoCRIXCg9iYW5uZWRfcGVlcl9pZHMYAiADKAkqlAEKEEVucm9sbG1lbnRTdGF0dXMSIQodRU5ST0xMTUVOVF9TVEFUVVNfVU5TUEVDSUZJRUQQABIdChlFTlJPTExNRU5UX1NUQVRVU19QRU5ESU5HEAESHgoaRU5ST0xMTUVOVF9TVEFUVVNfQVBQUk9WRUQQAhIeChpFTlJPTExNRU5UX1NUQVRVU19SRUpFQ1RFRBADKowBCgtTZXJ2aWNlVHlwZRIcChhTRVJWSUNFX1RZUEVfVU5TUEVDSUZJRUQQABIUChBTRVJWSUNFX1RZUEVfTUNQEAESGgoWU0VSVklDRV9UWVBFX0lORkVSRU5DRRACEhQKEFNFUlZJQ0VfVFlQRV9BMkEQAxIXChNTRVJWSUNFX1RZUEVfRUdSRVNTEAQqNwoKRWdyZXNzTW9kZRIUChBFR1JFU1NfTU9ERV9IVFRQEAASEwoPRUdSRVNTX01PREVfVENQEAEqXAoSUmVzcG9uc2VJbnNwZWN0aW9uEiAKHFJFU1BPTlNFX0lOU1BFQ1RJT05fQlVGRkVSRUQQABIkCiBSRVNQT05TRV9JTlNQRUNUSU9OX1JFUVVFU1RfT05MWRABQhtaGWdpdGh1Yi5jb20vZ29vZ2xlL3NhbS9hcGliBnByb3RvMw", [file_google_protobuf_duration, file_google_protobuf_timestamp]); /** * @generated from message sam.v1.AuthFrame @@ -1586,6 +1586,549 @@ export type TokenRevokeResponse = Message<"sam.v1.TokenRevokeResponse"> & { export const TokenRevokeResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_sam, 39); +/** + * BootstrapTokenCreateRequest is the body of POST /admin/bootstrap-tokens + * (admin bearer) and POST /user/bootstrap-tokens (mesh user). + * + * @generated from message sam.v1.BootstrapTokenCreateRequest + */ +export type BootstrapTokenCreateRequest = Message<"sam.v1.BootstrapTokenCreateRequest"> & { + /** + * Role the token enrolls into, e.g. "sam:role:node". Required on the admin + * endpoint; the user endpoint defaults it to "sam:role:node". + * + * @generated from field: string role = 1; + */ + role: string; + + /** + * User the token is issued on behalf of. Honored by the user endpoint + * only, and only for admins; defaults to the caller. + * + * @generated from field: string owner_id = 2; + */ + ownerId: string; + + /** + * How long the token stays valid; unset or non-positive means 24. + * + * @generated from field: int32 ttl_hours = 3; + */ + ttlHours: number; + + /** + * How many enrollments the token admits; unset or non-positive means 1. + * + * @generated from field: int32 max_usages = 4; + */ + maxUsages: number; + + /** + * Free-form operator note stored with the token. + * + * @generated from field: string description = 5; + */ + description: string; + + /** + * Copied onto every node the token enrolls: such a node may still refresh + * its credential after the control plane's signing key rotated past its + * grace period, on proof of possession of its own key alone. Admin-only, + * because a node that can always recover holds a credential that never + * expires. + * + * @generated from field: bool autonomous_recovery = 6; + */ + autonomousRecovery: boolean; +}; + +/** + * Describes the message sam.v1.BootstrapTokenCreateRequest. + * Use `create(BootstrapTokenCreateRequestSchema)` to create a new message. + */ +export const BootstrapTokenCreateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 40); + +/** + * BootstrapTokenCreateResponse is returned (201) when a token is minted. + * token is the plaintext and is shown exactly once; the control plane keeps + * only its hash, which is also the id. + * + * @generated from message sam.v1.BootstrapTokenCreateResponse + */ +export type BootstrapTokenCreateResponse = Message<"sam.v1.BootstrapTokenCreateResponse"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string token = 2; + */ + token: string; + + /** + * @generated from field: string role = 3; + */ + role: string; + + /** + * @generated from field: string owner_id = 4; + */ + ownerId: string; + + /** + * @generated from field: google.protobuf.Timestamp expire_time = 5; + */ + expireTime?: Timestamp | undefined; +}; + +/** + * Describes the message sam.v1.BootstrapTokenCreateResponse. + * Use `create(BootstrapTokenCreateResponseSchema)` to create a new message. + */ +export const BootstrapTokenCreateResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 41); + +/** + * BootstrapToken is a minted token as operators list it. + * + * @generated from message sam.v1.BootstrapToken + */ +export type BootstrapToken = Message<"sam.v1.BootstrapToken"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string role = 2; + */ + role: string; + + /** + * @generated from field: string owner_id = 3; + */ + ownerId: string; + + /** + * @generated from field: int32 max_usages = 4; + */ + maxUsages: number; + + /** + * @generated from field: int32 usages_count = 5; + */ + usagesCount: number; + + /** + * @generated from field: string description = 6; + */ + description: string; + + /** + * @generated from field: google.protobuf.Timestamp create_time = 7; + */ + createTime?: Timestamp | undefined; + + /** + * @generated from field: google.protobuf.Timestamp expire_time = 8; + */ + expireTime?: Timestamp | undefined; + + /** + * Set when an operator revoked the token, which is distinct from expiry + * or exhausted usages. Unset means never revoked. + * + * @generated from field: google.protobuf.Timestamp revoke_time = 9; + */ + revokeTime?: Timestamp | undefined; + + /** + * @generated from field: bool autonomous_recovery = 10; + */ + autonomousRecovery: boolean; +}; + +/** + * Describes the message sam.v1.BootstrapToken. + * Use `create(BootstrapTokenSchema)` to create a new message. + */ +export const BootstrapTokenSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 42); + +/** + * @generated from message sam.v1.BootstrapTokenListResponse + */ +export type BootstrapTokenListResponse = Message<"sam.v1.BootstrapTokenListResponse"> & { + /** + * @generated from field: repeated sam.v1.BootstrapToken tokens = 1; + */ + tokens: BootstrapToken[]; +}; + +/** + * Describes the message sam.v1.BootstrapTokenListResponse. + * Use `create(BootstrapTokenListResponseSchema)` to create a new message. + */ +export const BootstrapTokenListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 43); + +/** + * EnrollmentRequest is a bootstrap enrollment awaiting or past an operator + * decision (see BootstrapEnrollRequest). + * + * @generated from message sam.v1.EnrollmentRequest + */ +export type EnrollmentRequest = Message<"sam.v1.EnrollmentRequest"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string peer_id = 2; + */ + peerId: string; + + /** + * The bootstrap token the request was made with. + * + * @generated from field: string token_id = 3; + */ + tokenId: string; + + /** + * @generated from field: sam.v1.EnrollmentStatus status = 4; + */ + status: EnrollmentStatus; + + /** + * Labels the node declared; approval attests them into its biscuit. + * + * @generated from field: map labels = 5; + */ + labels: { [key: string]: string }; + + /** + * @generated from field: google.protobuf.Timestamp create_time = 6; + */ + createTime?: Timestamp | undefined; + + /** + * Unset while the request is pending. + * + * @generated from field: google.protobuf.Timestamp resolve_time = 7; + */ + resolveTime?: Timestamp | undefined; + + /** + * @generated from field: string resolved_by = 8; + */ + resolvedBy: string; +}; + +/** + * Describes the message sam.v1.EnrollmentRequest. + * Use `create(EnrollmentRequestSchema)` to create a new message. + */ +export const EnrollmentRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 44); + +/** + * @generated from message sam.v1.EnrollmentRequestListResponse + */ +export type EnrollmentRequestListResponse = Message<"sam.v1.EnrollmentRequestListResponse"> & { + /** + * @generated from field: repeated sam.v1.EnrollmentRequest requests = 1; + */ + requests: EnrollmentRequest[]; +}; + +/** + * Describes the message sam.v1.EnrollmentRequestListResponse. + * Use `create(EnrollmentRequestListResponseSchema)` to create a new message. + */ +export const EnrollmentRequestListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 45); + +/** + * User is a human identity known to the mesh. + * + * @generated from message sam.v1.User + */ +export type User = Message<"sam.v1.User"> & { + /** + * The identity provider's subject. + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string issuer = 2; + */ + issuer: string; + + /** + * @generated from field: string email = 3; + */ + email: string; + + /** + * "admin" or "user". + * + * @generated from field: string role = 4; + */ + role: string; + + /** + * @generated from field: google.protobuf.Timestamp create_time = 5; + */ + createTime?: Timestamp | undefined; +}; + +/** + * Describes the message sam.v1.User. + * Use `create(UserSchema)` to create a new message. + */ +export const UserSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 46); + +/** + * EnrolledNode is a member's enrollment record without its credential. + * + * @generated from message sam.v1.EnrolledNode + */ +export type EnrolledNode = Message<"sam.v1.EnrolledNode"> & { + /** + * @generated from field: string peer_id = 1; + */ + peerId: string; + + /** + * @generated from field: string role = 2; + */ + role: string; + + /** + * How the node enrolled, e.g. "oidc" or "bootstrap". + * + * @generated from field: string enrollment_type = 3; + */ + enrollmentType: string; + + /** + * The identity provider's claims as stored at enrollment. Admin-only. + * + * @generated from field: string claims_json = 4; + */ + claimsJson: string; + + /** + * @generated from field: string owner_id = 5; + */ + ownerId: string; + + /** + * @generated from field: map labels = 6; + */ + labels: { [key: string]: string }; + + /** + * @generated from field: google.protobuf.Timestamp enroll_time = 7; + */ + enrollTime?: Timestamp | undefined; + + /** + * When the enrollment session ends; unset means it does not expire. + * + * @generated from field: google.protobuf.Timestamp expire_time = 8; + */ + expireTime?: Timestamp | undefined; + + /** + * @generated from field: bool banned = 9; + */ + banned: boolean; + + /** + * @generated from field: bool autonomous_recovery = 10; + */ + autonomousRecovery: boolean; +}; + +/** + * Describes the message sam.v1.EnrolledNode. + * Use `create(EnrolledNodeSchema)` to create a new message. + */ +export const EnrolledNodeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 47); + +/** + * RouterLease is a router's current registration with the control plane. + * + * @generated from message sam.v1.RouterLease + */ +export type RouterLease = Message<"sam.v1.RouterLease"> & { + /** + * @generated from field: string peer_id = 1; + */ + peerId: string; + + /** + * Multiaddrs, `/p2p/` suffixed. + * + * @generated from field: repeated string addresses = 2; + */ + addresses: string[]; + + /** + * @generated from field: google.protobuf.Timestamp last_renewal_time = 3; + */ + lastRenewalTime?: Timestamp | undefined; + + /** + * @generated from field: google.protobuf.Timestamp expire_time = 4; + */ + expireTime?: Timestamp | undefined; + + /** + * @generated from field: repeated string connected_peers = 5; + */ + connectedPeers: string[]; + + /** + * @generated from field: int32 dht_size = 6; + */ + dhtSize: number; +}; + +/** + * Describes the message sam.v1.RouterLease. + * Use `create(RouterLeaseSchema)` to create a new message. + */ +export const RouterLeaseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 48); + +/** + * NodeServices is the services one node last reported (see + * NodeCatalogReport). + * + * @generated from message sam.v1.NodeServices + */ +export type NodeServices = Message<"sam.v1.NodeServices"> & { + /** + * @generated from field: repeated sam.v1.ServiceInfo services = 1; + */ + services: ServiceInfo[]; + + /** + * @generated from field: google.protobuf.Timestamp report_time = 2; + */ + reportTime?: Timestamp | undefined; +}; + +/** + * Describes the message sam.v1.NodeServices. + * Use `create(NodeServicesSchema)` to create a new message. + */ +export const NodeServicesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 49); + +/** + * AdminStatusResponse answers GET /admin/status: everything the console + * shows an administrator. + * + * @generated from message sam.v1.AdminStatusResponse + */ +export type AdminStatusResponse = Message<"sam.v1.AdminStatusResponse"> & { + /** + * @generated from field: repeated sam.v1.User users = 1; + */ + users: User[]; + + /** + * @generated from field: repeated sam.v1.RouterLease active_routers = 2; + */ + activeRouters: RouterLease[]; + + /** + * @generated from field: repeated sam.v1.EnrolledNode enrolled_nodes = 3; + */ + enrolledNodes: EnrolledNode[]; + + /** + * @generated from field: repeated sam.v1.EnrollmentRequest enrollment_requests = 4; + */ + enrollmentRequests: EnrollmentRequest[]; + + /** + * @generated from field: repeated sam.v1.BootstrapToken bootstrap_tokens = 5; + */ + bootstrapTokens: BootstrapToken[]; + + /** + * @generated from field: sam.v1.PolicyConfig policy = 6; + */ + policy?: PolicyConfig | undefined; + + /** + * Keyed by the reporting node's peer ID; admitted nodes only. + * + * @generated from field: map node_catalog = 7; + */ + nodeCatalog: { [key: string]: NodeServices }; +}; + +/** + * Describes the message sam.v1.AdminStatusResponse. + * Use `create(AdminStatusResponseSchema)` to create a new message. + */ +export const AdminStatusResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 50); + +/** + * UserStatusResponse answers GET /user/status: the caller and what it owns. + * The router fleet and the mesh policy describe the whole mesh and are set + * for an administrator only. + * + * @generated from message sam.v1.UserStatusResponse + */ +export type UserStatusResponse = Message<"sam.v1.UserStatusResponse"> & { + /** + * @generated from field: sam.v1.User user = 1; + */ + user?: User | undefined; + + /** + * @generated from field: repeated sam.v1.EnrolledNode enrolled_nodes = 2; + */ + enrolledNodes: EnrolledNode[]; + + /** + * @generated from field: repeated sam.v1.BootstrapToken bootstrap_tokens = 3; + */ + bootstrapTokens: BootstrapToken[]; + + /** + * @generated from field: repeated sam.v1.RouterLease active_routers = 4; + */ + activeRouters: RouterLease[]; + + /** + * @generated from field: sam.v1.PolicyConfig policy = 5; + */ + policy?: PolicyConfig | undefined; +}; + +/** + * Describes the message sam.v1.UserStatusResponse. + * Use `create(UserStatusResponseSchema)` to create a new message. + */ +export const UserStatusResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 51); + /** * @generated from message sam.v1.IdentityEvidenceResponse */ @@ -1628,7 +2171,7 @@ export type IdentityEvidenceResponse = Message<"sam.v1.IdentityEvidenceResponse" * Use `create(IdentityEvidenceResponseSchema)` to create a new message. */ export const IdentityEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 40); + messageDesc(file_sam, 52); /** * @generated from message sam.v1.PeerEvidenceResponse @@ -1684,7 +2227,7 @@ export type PeerEvidenceResponse = Message<"sam.v1.PeerEvidenceResponse"> & { * Use `create(PeerEvidenceResponseSchema)` to create a new message. */ export const PeerEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 41); + messageDesc(file_sam, 53); /** * @generated from message sam.v1.MemberCredential @@ -1749,7 +2292,7 @@ export type MemberCredential = Message<"sam.v1.MemberCredential"> & { * Use `create(MemberCredentialSchema)` to create a new message. */ export const MemberCredentialSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 42); + messageDesc(file_sam, 54); /** * @generated from message sam.v1.TrustedSigningKey @@ -1776,7 +2319,7 @@ export type TrustedSigningKey = Message<"sam.v1.TrustedSigningKey"> & { * Use `create(TrustedSigningKeySchema)` to create a new message. */ export const TrustedSigningKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 43); + messageDesc(file_sam, 55); /** * @generated from message sam.v1.OIDCSession @@ -1808,7 +2351,7 @@ export type OIDCSession = Message<"sam.v1.OIDCSession"> & { * Use `create(OIDCSessionSchema)` to create a new message. */ export const OIDCSessionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 44); + messageDesc(file_sam, 56); /** * TaskAuthorizationRule narrows a credential's authority for a specific task or @@ -1852,7 +2395,7 @@ export type TaskAuthorizationRule = Message<"sam.v1.TaskAuthorizationRule"> & { * Use `create(TaskAuthorizationRuleSchema)` to create a new message. */ export const TaskAuthorizationRuleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 45); + messageDesc(file_sam, 57); /** * @generated from message sam.v1.TaskRule @@ -1896,7 +2439,7 @@ export type TaskRule = Message<"sam.v1.TaskRule"> & { * Use `create(TaskRuleSchema)` to create a new message. */ export const TaskRuleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 46); + messageDesc(file_sam, 58); /** * @generated from message sam.v1.TaskOperation @@ -1938,7 +2481,7 @@ export type TaskOperation = Message<"sam.v1.TaskOperation"> & { * Use `create(TaskOperationSchema)` to create a new message. */ export const TaskOperationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 47); + messageDesc(file_sam, 59); /** * TokenExchangeRequest is the body of POST /token/exchange on the control @@ -1996,7 +2539,7 @@ export type TokenExchangeRequest = Message<"sam.v1.TokenExchangeRequest"> & { * Use `create(TokenExchangeRequestSchema)` to create a new message. */ export const TokenExchangeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 48); + messageDesc(file_sam, 60); /** * @generated from message sam.v1.TokenExchangeResponse @@ -2028,7 +2571,7 @@ export type TokenExchangeResponse = Message<"sam.v1.TokenExchangeResponse"> & { * Use `create(TokenExchangeResponseSchema)` to create a new message. */ export const TokenExchangeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 49); + messageDesc(file_sam, 61); /** * STSTokenRequest is the body of POST /sts/token on the control plane: an @@ -2082,7 +2625,7 @@ export type STSTokenRequest = Message<"sam.v1.STSTokenRequest"> & { * Use `create(STSTokenRequestSchema)` to create a new message. */ export const STSTokenRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 50); + messageDesc(file_sam, 62); /** * @generated from message sam.v1.STSTokenResponse @@ -2119,7 +2662,7 @@ export type STSTokenResponse = Message<"sam.v1.STSTokenResponse"> & { * Use `create(STSTokenResponseSchema)` to create a new message. */ export const STSTokenResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 51); + messageDesc(file_sam, 63); /** * RevocationsResponse answers GET /revocations on the control plane: the set of @@ -2145,7 +2688,7 @@ export type RevocationsResponse = Message<"sam.v1.RevocationsResponse"> & { * Use `create(RevocationsResponseSchema)` to create a new message. */ export const RevocationsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 52); + messageDesc(file_sam, 64); /** * @generated from enum sam.v1.EnrollmentStatus diff --git a/sdk/js/src/mesh.test.ts b/sdk/js/src/mesh.test.ts index b9605c36..5b122029 100644 --- a/sdk/js/src/mesh.test.ts +++ b/sdk/js/src/mesh.test.ts @@ -42,7 +42,10 @@ function proto(bytes: Uint8Array): Response { } /** A control plane that approves everything and hands out numbered biscuits. */ -function fakeControlPlane(keysOk = true): { fetch: typeof fetch; issued: number; lastRefreshJwt: string; keys: Identity[]; keysOk: boolean } { +function fakeControlPlane( + keysOk = true, + opts: { strictRefresh?: boolean; refreshDelayMs?: number; rejectNextRefresh?: boolean } = {}, +): { fetch: typeof fetch; issued: number; lastRefreshJwt: string; keys: Identity[]; keysOk: boolean } { // keys is what /keys serves and signs with; a test rotates by replacing it. const state = { issued: 0, lastRefreshJwt: "", keys: [cpKey], keysOk }; const current = () => (state.keys[state.keys.length - 1] as Identity).publicKeyRaw; @@ -71,6 +74,20 @@ function fakeControlPlane(keysOk = true): { fetch: typeof fetch; issued: number; ); case "POST /refresh": { const refreshReq = fromBinary(TokenRefreshRequestSchema, new Uint8Array(await req.arrayBuffer())); + if (opts.rejectNextRefresh) { + opts.rejectNextRefresh = false; + return new Response("control plane unavailable", { status: 503 }); + } + if (opts.strictRefresh) { + // The real control plane redeems only the last biscuit it issued. + const presented = Buffer.from(req.headers.get("Authorization")?.replace(/^Bearer /, "") ?? "", "base64").toString(); + if (presented !== `biscuit-${state.issued}`) { + return new Response(`stale biscuit ${presented}`, { status: 401 }); + } + } + if (opts.refreshDelayMs) { + await new Promise((resolve) => setTimeout(resolve, opts.refreshDelayMs)); + } state.lastRefreshJwt = refreshReq.jwt; state.issued++; return proto( @@ -248,6 +265,35 @@ test("enroll works without a state directory and keeps the enrollment key when / assert.deepEqual(mesh.credential.biscuit, text("biscuit-2")); }); +// A session refreshes on its own schedule while a pull from the control +// plane may refresh too. The control plane redeems only the last biscuit it +// issued and both write the same state file, so refreshes must run one +// after another (sam-node's refreshMu, the Python SDK's lock). +test("concurrent refreshes run one after the other", async () => { + const dir = await mkdtemp(join(tmpdir(), "sam-sdk-")); + try { + const opts = { strictRefresh: true, refreshDelayMs: 10, rejectNextRefresh: false }; + const cp = fakeControlPlane(true, opts); + const mesh = await AgentMesh.enroll({ controlPlaneUrl: "http://127.0.0.1:1", bootstrapToken: "sbt_secret", stateDir: join(dir, "state"), fetch: cp.fetch }); + + const results = await Promise.allSettled(Array.from({ length: 8 }, () => mesh.refresh())); + const failures = results.filter((r) => r.status === "rejected"); + assert.deepEqual(failures, []); + assert.deepEqual(mesh.credential.biscuit, text("biscuit-9")); + const onDisk = JSON.parse(await readFile(join(dir, "state", "credential.json"), "utf8")) as Record; + assert.equal(Buffer.from(onDisk.biscuit as string, "base64").toString(), "biscuit-9"); + + // A failed refresh does not block the one queued behind it. + opts.rejectNextRefresh = true; + const [failed, next] = await Promise.allSettled([mesh.refresh(), mesh.refresh()]); + assert.equal(failed?.status, "rejected"); + assert.equal(next?.status, "fulfilled"); + assert.deepEqual(mesh.credential.biscuit, text("biscuit-10")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("enroll refuses ambiguous credentials", async () => { const cp = fakeControlPlane(); await assert.rejects(AgentMesh.enroll({ controlPlaneUrl: "http://127.0.0.1:1", fetch: cp.fetch }), /exactly one of/); diff --git a/sdk/js/src/mesh.ts b/sdk/js/src/mesh.ts index ae00d709..9b996e95 100644 --- a/sdk/js/src/mesh.ts +++ b/sdk/js/src/mesh.ts @@ -143,6 +143,10 @@ export class AgentMesh { #credential: MeshCredential; readonly #state: StateStore | undefined; readonly #jwtSource: (() => Promise) | undefined; + // Refreshes run one after another, as sam-node's refreshMu: the control + // plane redeems only the last biscuit it issued, so two in flight would + // leave the loser holding a spent one. + #refreshChain: Promise = Promise.resolve(); private constructor( identity: Identity, @@ -297,8 +301,21 @@ export class AgentMesh { * Trades the current biscuit for a fresh one and persists it. The control * plane redeems only the last biscuit it issued, so a lost refresh result * means re-enrolling; persisting before returning keeps that rare. + * Concurrent calls wait for each other. */ async refresh(): Promise { + const run = this.#refreshChain.then( + () => this.#refreshOnce(), + () => this.#refreshOnce(), + ); + this.#refreshChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + async #refreshOnce(): Promise { let jwt: string | undefined; if (this.#jwtSource !== undefined) { try { diff --git a/sdk/python/src/agent_mesh/_proto/sam_pb2.py b/sdk/python/src/agent_mesh/_proto/sam_pb2.py index 913ace80..c8261ab2 100644 --- a/sdk/python/src/agent_mesh/_proto/sam_pb2.py +++ b/sdk/python/src/agent_mesh/_proto/sam_pb2.py @@ -15,7 +15,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tsam.proto\x12\x06sam.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"4\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xd6\x01\n\tMeshEvent\x12$\n\x04type\x18\x01 \x01(\x0e\x32\x16.sam.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf3\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x31\n\x06labels\x18\x05 \x03(\x0b\x32!.sam.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x91\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12:\n\x06labels\x18\x05 \x03(\x0b\x32*.sam.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xfd\x01\n\x17\x42ootstrapEnrollResponse\x12(\n\x06status\x18\x01 \x01(\x0e\x32\x18.sam.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"S\n\x0bServiceInfo\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"{\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12,\n\x03\x65nv\x18\x02 \x03(\x0b\x32\x1f.sam.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8a\x01\n\x16RegisterServiceRequest\x12$\n\x07service\x18\x01 \x01(\x0b\x32\x13.sam.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12)\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x16.sam.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xb2\x02\n\x0fServiceAnnounce\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12!\n\x04type\x18\x02 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x0c\n\x04keys\x18\x04 \x03(\t\x12\x33\n\x06labels\x18\x05 \x03(\x0b\x32#.sam.v1.ServiceAnnounce.LabelsEntry\x12\x17\n\x0f\x61\x63tive_requests\x18\x06 \x01(\r\x12\x17\n\x0flatency_ewma_ms\x18\x07 \x01(\x01\x12\x31\n\rannounce_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\"\xac\x01\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x9e\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x05 \x03(\t\x12\x1f\n\x04http\x18\x06 \x03(\x0b\x32\x11.sam.v1.HTTPGrant\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\x8f\x02\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\x12(\n\x06\x62roker\x18\x05 \x01(\x0b\x32\x18.sam.v1.CredentialBroker\x12&\n\ninspection\x18\x06 \x01(\x0b\x32\x12.sam.v1.Inspection\x12 \n\x04mode\x18\x07 \x01(\x0e\x32\x12.sam.v1.EgressMode\x12\r\n\x05ports\x18\x08 \x03(\r\x12\x15\n\rpreserve_host\x18\t \x01(\x08\x12\x17\n\x0f\x66orward_context\x18\n \x01(\x08\"3\n\nInspection\x12%\n\ninspectors\x18\x01 \x03(\x0b\x32\x11.sam.v1.Inspector\"c\n\tInspector\x12)\n\x0bmodel_armor\x18\x01 \x01(\x0b\x32\x12.sam.v1.ModelArmorH\x00\x12#\n\x08\x65xt_proc\x18\x02 \x01(\x0b\x32\x0f.sam.v1.ExtProcH\x00\x42\x06\n\x04kind\"\x8b\x01\n\nModelArmor\x12\x10\n\x08template\x18\x01 \x01(\t\x12,\n\x08response\x18\x02 \x01(\x0e\x32\x1a.sam.v1.ResponseInspection\x12\x11\n\tfail_open\x18\x03 \x01(\x08\x12*\n\x07timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x82\x02\n\x07\x45xtProc\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\n\n\x02\x63\x61\x18\x02 \x01(\t\x12\x1a\n\x12\x63lient_certificate\x18\x03 \x01(\t\x12\x36\n\x0fprocessing_mode\x18\x04 \x01(\x0b\x32\x1d.sam.v1.ExtProcProcessingMode\x12\x1b\n\x13\x61llow_mode_override\x18\x05 \x01(\x08\x12\x32\n\x0fmessage_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1a\n\x12\x66\x61ilure_mode_allow\x18\x07 \x01(\x08\x12\x1a\n\x12max_buffered_bytes\x18\x08 \x01(\r\"\xdb\x04\n\x15\x45xtProcProcessingMode\x12\x45\n\x13request_header_mode\x18\x01 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\x12\x46\n\x14response_header_mode\x18\x02 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\x12\x41\n\x11request_body_mode\x18\x03 \x01(\x0e\x32&.sam.v1.ExtProcProcessingMode.BodyMode\x12\x42\n\x12response_body_mode\x18\x04 \x01(\x0e\x32&.sam.v1.ExtProcProcessingMode.BodyMode\x12\x46\n\x14request_trailer_mode\x18\x05 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\x12G\n\x15response_trailer_mode\x18\x06 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\"9\n\nHeaderMode\x12\x17\n\x13HEADER_MODE_DEFAULT\x10\x00\x12\x08\n\x04SEND\x10\x01\x12\x08\n\x04SKIP\x10\x02\"`\n\x08\x42odyMode\x12\x08\n\x04NONE\x10\x00\x12\x0c\n\x08STREAMED\x10\x01\x12\x0c\n\x08\x42UFFERED\x10\x02\x12\x14\n\x10\x42UFFERED_PARTIAL\x10\x03\x12\x18\n\x14\x46ULL_DUPLEX_STREAMED\x10\x04\"\xcf\x01\n\x10\x43redentialBroker\x12\x17\n\rstatic_secret\x18\x01 \x01(\tH\x00\x12\x31\n\x0foidc_federation\x18\x02 \x01(\x0b\x32\x16.sam.v1.OIDCFederationH\x00\x12\x30\n\x0f\x61ws_assume_role\x18\x03 \x01(\x0b\x32\x15.sam.v1.AWSAssumeRoleH\x00\x12\x35\n\x11platform_identity\x18\x04 \x01(\x0b\x32\x18.sam.v1.PlatformIdentityH\x00\x42\x06\n\x04kind\"_\n\x0eOIDCFederation\x12\x16\n\x0etoken_endpoint\x18\x01 \x01(\t\x12\x10\n\x08\x61udience\x18\x02 \x01(\t\x12\x13\n\x0bimpersonate\x18\x03 \x01(\t\x12\x0e\n\x06scopes\x18\x04 \x03(\t\"9\n\rAWSAssumeRole\x12\x10\n\x08role_arn\x18\x01 \x01(\t\x12\x16\n\x0esession_policy\x18\x02 \x01(\t\"\"\n\x10PlatformIdentity\x12\x0e\n\x06scopes\x18\x01 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x85\x01\n\x0cPolicyConfig\x12!\n\x05roles\x18\x01 \x03(\x0b\x32\x12.sam.v1.PolicyRole\x12\'\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x15.sam.v1.PolicyBinding\x12)\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"0\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x01 \x03(\t\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"F\n\x19\x45gressAssignmentsResponse\x12)\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"k\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\x12\x0b\n\x03jwt\x18\x04 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\":\n\x11NodeCatalogReport\x12%\n\x08services\x18\x01 \x03(\x0b\x32\x13.sam.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc0\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x38\n\x06labels\x18\x05 \x03(\x0b\x32(.sam.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x80\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x19.sam.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12)\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x13.sam.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t\"\x8d\x01\n\x15TaskAuthorizationRule\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x1f\n\x05rules\x18\x03 \x03(\x0b\x32\x10.sam.v1.TaskRule\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"~\n\x08TaskRule\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x18\n\x10\x61llowed_services\x18\x02 \x03(\t\x12(\n\toperation\x18\x03 \x01(\x0b\x32\x15.sam.v1.TaskOperation\x12\x19\n\x11\x61llowed_resources\x18\x04 \x03(\t\"s\n\rTaskOperation\x12\x15\n\rallowed_tools\x18\x01 \x03(\t\x12\x17\n\x0f\x61llowed_methods\x18\x02 \x03(\t\x12\x15\n\rallowed_paths\x18\x03 \x03(\t\x12\x1b\n\x13\x61llowed_permissions\x18\x04 \x03(\t\"\xa5\x01\n\x14TokenExchangeRequest\x12\x15\n\rsubject_token\x18\x01 \x01(\t\x12\x30\n\ttask_rule\x18\x02 \x01(\x0b\x32\x1d.sam.v1.TaskAuthorizationRule\x12\x0c\n\x04seal\x18\x03 \x01(\x08\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x7f\n\x15TokenExchangeResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x0f\n\x07subject\x18\x04 \x01(\t\"\x81\x01\n\x0fSTSTokenRequest\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x83\x01\n\x10STSTokenResponse\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07subject\x18\x03 \x01(\t\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x11\n\ttask_name\x18\x05 \x01(\t\"F\n\x13RevocationsResponse\x12\x16\n\x0erevocation_ids\x18\x01 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x02 \x03(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04*7\n\nEgressMode\x12\x14\n\x10\x45GRESS_MODE_HTTP\x10\x00\x12\x13\n\x0f\x45GRESS_MODE_TCP\x10\x01*\\\n\x12ResponseInspection\x12 \n\x1cRESPONSE_INSPECTION_BUFFERED\x10\x00\x12$\n RESPONSE_INSPECTION_REQUEST_ONLY\x10\x01\x42\x1bZ\x19github.com/google/sam/apib\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tsam.proto\x12\x06sam.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"4\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xd6\x01\n\tMeshEvent\x12$\n\x04type\x18\x01 \x01(\x0e\x32\x16.sam.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf3\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x31\n\x06labels\x18\x05 \x03(\x0b\x32!.sam.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x91\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12:\n\x06labels\x18\x05 \x03(\x0b\x32*.sam.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xfd\x01\n\x17\x42ootstrapEnrollResponse\x12(\n\x06status\x18\x01 \x01(\x0e\x32\x18.sam.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"S\n\x0bServiceInfo\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"{\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12,\n\x03\x65nv\x18\x02 \x03(\x0b\x32\x1f.sam.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8a\x01\n\x16RegisterServiceRequest\x12$\n\x07service\x18\x01 \x01(\x0b\x32\x13.sam.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12)\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x16.sam.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xb2\x02\n\x0fServiceAnnounce\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12!\n\x04type\x18\x02 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x0c\n\x04keys\x18\x04 \x03(\t\x12\x33\n\x06labels\x18\x05 \x03(\x0b\x32#.sam.v1.ServiceAnnounce.LabelsEntry\x12\x17\n\x0f\x61\x63tive_requests\x18\x06 \x01(\r\x12\x17\n\x0flatency_ewma_ms\x18\x07 \x01(\x01\x12\x31\n\rannounce_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\"\xac\x01\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x9e\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x05 \x03(\t\x12\x1f\n\x04http\x18\x06 \x03(\x0b\x32\x11.sam.v1.HTTPGrant\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\x8f\x02\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\x12(\n\x06\x62roker\x18\x05 \x01(\x0b\x32\x18.sam.v1.CredentialBroker\x12&\n\ninspection\x18\x06 \x01(\x0b\x32\x12.sam.v1.Inspection\x12 \n\x04mode\x18\x07 \x01(\x0e\x32\x12.sam.v1.EgressMode\x12\r\n\x05ports\x18\x08 \x03(\r\x12\x15\n\rpreserve_host\x18\t \x01(\x08\x12\x17\n\x0f\x66orward_context\x18\n \x01(\x08\"3\n\nInspection\x12%\n\ninspectors\x18\x01 \x03(\x0b\x32\x11.sam.v1.Inspector\"c\n\tInspector\x12)\n\x0bmodel_armor\x18\x01 \x01(\x0b\x32\x12.sam.v1.ModelArmorH\x00\x12#\n\x08\x65xt_proc\x18\x02 \x01(\x0b\x32\x0f.sam.v1.ExtProcH\x00\x42\x06\n\x04kind\"\x8b\x01\n\nModelArmor\x12\x10\n\x08template\x18\x01 \x01(\t\x12,\n\x08response\x18\x02 \x01(\x0e\x32\x1a.sam.v1.ResponseInspection\x12\x11\n\tfail_open\x18\x03 \x01(\x08\x12*\n\x07timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x82\x02\n\x07\x45xtProc\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\n\n\x02\x63\x61\x18\x02 \x01(\t\x12\x1a\n\x12\x63lient_certificate\x18\x03 \x01(\t\x12\x36\n\x0fprocessing_mode\x18\x04 \x01(\x0b\x32\x1d.sam.v1.ExtProcProcessingMode\x12\x1b\n\x13\x61llow_mode_override\x18\x05 \x01(\x08\x12\x32\n\x0fmessage_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1a\n\x12\x66\x61ilure_mode_allow\x18\x07 \x01(\x08\x12\x1a\n\x12max_buffered_bytes\x18\x08 \x01(\r\"\xdb\x04\n\x15\x45xtProcProcessingMode\x12\x45\n\x13request_header_mode\x18\x01 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\x12\x46\n\x14response_header_mode\x18\x02 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\x12\x41\n\x11request_body_mode\x18\x03 \x01(\x0e\x32&.sam.v1.ExtProcProcessingMode.BodyMode\x12\x42\n\x12response_body_mode\x18\x04 \x01(\x0e\x32&.sam.v1.ExtProcProcessingMode.BodyMode\x12\x46\n\x14request_trailer_mode\x18\x05 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\x12G\n\x15response_trailer_mode\x18\x06 \x01(\x0e\x32(.sam.v1.ExtProcProcessingMode.HeaderMode\"9\n\nHeaderMode\x12\x17\n\x13HEADER_MODE_DEFAULT\x10\x00\x12\x08\n\x04SEND\x10\x01\x12\x08\n\x04SKIP\x10\x02\"`\n\x08\x42odyMode\x12\x08\n\x04NONE\x10\x00\x12\x0c\n\x08STREAMED\x10\x01\x12\x0c\n\x08\x42UFFERED\x10\x02\x12\x14\n\x10\x42UFFERED_PARTIAL\x10\x03\x12\x18\n\x14\x46ULL_DUPLEX_STREAMED\x10\x04\"\xcf\x01\n\x10\x43redentialBroker\x12\x17\n\rstatic_secret\x18\x01 \x01(\tH\x00\x12\x31\n\x0foidc_federation\x18\x02 \x01(\x0b\x32\x16.sam.v1.OIDCFederationH\x00\x12\x30\n\x0f\x61ws_assume_role\x18\x03 \x01(\x0b\x32\x15.sam.v1.AWSAssumeRoleH\x00\x12\x35\n\x11platform_identity\x18\x04 \x01(\x0b\x32\x18.sam.v1.PlatformIdentityH\x00\x42\x06\n\x04kind\"_\n\x0eOIDCFederation\x12\x16\n\x0etoken_endpoint\x18\x01 \x01(\t\x12\x10\n\x08\x61udience\x18\x02 \x01(\t\x12\x13\n\x0bimpersonate\x18\x03 \x01(\t\x12\x0e\n\x06scopes\x18\x04 \x03(\t\"9\n\rAWSAssumeRole\x12\x10\n\x08role_arn\x18\x01 \x01(\t\x12\x16\n\x0esession_policy\x18\x02 \x01(\t\"\"\n\x10PlatformIdentity\x12\x0e\n\x06scopes\x18\x01 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x85\x01\n\x0cPolicyConfig\x12!\n\x05roles\x18\x01 \x03(\x0b\x32\x12.sam.v1.PolicyRole\x12\'\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x15.sam.v1.PolicyBinding\x12)\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"0\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x01 \x03(\t\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"F\n\x19\x45gressAssignmentsResponse\x12)\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"k\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\x12\x0b\n\x03jwt\x18\x04 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\":\n\x11NodeCatalogReport\x12%\n\x08services\x18\x01 \x03(\x0b\x32\x13.sam.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x96\x01\n\x1b\x42ootstrapTokenCreateRequest\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x10\n\x08owner_id\x18\x02 \x01(\t\x12\x11\n\tttl_hours\x18\x03 \x01(\x05\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\x1b\n\x13\x61utonomous_recovery\x18\x06 \x01(\x08\"\x8a\x01\n\x1c\x42ootstrapTokenCreateResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x0c\n\x04role\x18\x03 \x01(\t\x12\x10\n\x08owner_id\x18\x04 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xab\x02\n\x0e\x42ootstrapToken\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x10\n\x08owner_id\x18\x03 \x01(\t\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x14\n\x0cusages_count\x18\x05 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12/\n\x0b\x63reate_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0brevoke_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\"D\n\x1a\x42ootstrapTokenListResponse\x12&\n\x06tokens\x18\x01 \x03(\x0b\x32\x16.sam.v1.BootstrapToken\"\xca\x02\n\x11\x45nrollmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x10\n\x08token_id\x18\x03 \x01(\t\x12(\n\x06status\x18\x04 \x01(\x0e\x32\x18.sam.v1.EnrollmentStatus\x12\x35\n\x06labels\x18\x05 \x03(\x0b\x32%.sam.v1.EnrollmentRequest.LabelsEntry\x12/\n\x0b\x63reate_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cresolve_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x13\n\x0bresolved_by\x18\x08 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"L\n\x1d\x45nrollmentRequestListResponse\x12+\n\x08requests\x18\x01 \x03(\x0b\x32\x19.sam.v1.EnrollmentRequest\"p\n\x04User\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0e\n\x06issuer\x18\x02 \x01(\t\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12\x0c\n\x04role\x18\x04 \x01(\t\x12/\n\x0b\x63reate_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x02\n\x0c\x45nrolledNode\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x17\n\x0f\x65nrollment_type\x18\x03 \x01(\t\x12\x13\n\x0b\x63laims_json\x18\x04 \x01(\t\x12\x10\n\x08owner_id\x18\x05 \x01(\t\x12\x30\n\x06labels\x18\x06 \x03(\x0b\x32 .sam.v1.EnrolledNode.LabelsEntry\x12/\n\x0b\x65nroll_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06\x62\x61nned\x18\t \x01(\x08\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xc4\x01\n\x0bRouterLease\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x35\n\x11last_renewal_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x17\n\x0f\x63onnected_peers\x18\x05 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x06 \x01(\x05\"f\n\x0cNodeServices\x12%\n\x08services\x18\x01 \x03(\x0b\x32\x13.sam.v1.ServiceInfo\x12/\n\x0breport_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xab\x03\n\x13\x41\x64minStatusResponse\x12\x1b\n\x05users\x18\x01 \x03(\x0b\x32\x0c.sam.v1.User\x12+\n\x0e\x61\x63tive_routers\x18\x02 \x03(\x0b\x32\x13.sam.v1.RouterLease\x12,\n\x0e\x65nrolled_nodes\x18\x03 \x03(\x0b\x32\x14.sam.v1.EnrolledNode\x12\x36\n\x13\x65nrollment_requests\x18\x04 \x03(\x0b\x32\x19.sam.v1.EnrollmentRequest\x12\x30\n\x10\x62ootstrap_tokens\x18\x05 \x03(\x0b\x32\x16.sam.v1.BootstrapToken\x12$\n\x06policy\x18\x06 \x01(\x0b\x32\x14.sam.v1.PolicyConfig\x12\x42\n\x0cnode_catalog\x18\x07 \x03(\x0b\x32,.sam.v1.AdminStatusResponse.NodeCatalogEntry\x1aH\n\x10NodeCatalogEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12#\n\x05value\x18\x02 \x01(\x0b\x32\x14.sam.v1.NodeServices:\x02\x38\x01\"\xe3\x01\n\x12UserStatusResponse\x12\x1a\n\x04user\x18\x01 \x01(\x0b\x32\x0c.sam.v1.User\x12,\n\x0e\x65nrolled_nodes\x18\x02 \x03(\x0b\x32\x14.sam.v1.EnrolledNode\x12\x30\n\x10\x62ootstrap_tokens\x18\x03 \x03(\x0b\x32\x16.sam.v1.BootstrapToken\x12+\n\x0e\x61\x63tive_routers\x18\x04 \x03(\x0b\x32\x13.sam.v1.RouterLease\x12$\n\x06policy\x18\x05 \x01(\x0b\x32\x14.sam.v1.PolicyConfig\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc0\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x38\n\x06labels\x18\x05 \x03(\x0b\x32(.sam.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x80\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x19.sam.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12)\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x13.sam.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t\"\x8d\x01\n\x15TaskAuthorizationRule\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12\x1f\n\x05rules\x18\x03 \x03(\x0b\x32\x10.sam.v1.TaskRule\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"~\n\x08TaskRule\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x18\n\x10\x61llowed_services\x18\x02 \x03(\t\x12(\n\toperation\x18\x03 \x01(\x0b\x32\x15.sam.v1.TaskOperation\x12\x19\n\x11\x61llowed_resources\x18\x04 \x03(\t\"s\n\rTaskOperation\x12\x15\n\rallowed_tools\x18\x01 \x03(\t\x12\x17\n\x0f\x61llowed_methods\x18\x02 \x03(\t\x12\x15\n\rallowed_paths\x18\x03 \x03(\t\x12\x1b\n\x13\x61llowed_permissions\x18\x04 \x03(\t\"\xa5\x01\n\x14TokenExchangeRequest\x12\x15\n\rsubject_token\x18\x01 \x01(\t\x12\x30\n\ttask_rule\x18\x02 \x01(\x0b\x32\x1d.sam.v1.TaskAuthorizationRule\x12\x0c\n\x04seal\x18\x03 \x01(\x08\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x7f\n\x15TokenExchangeResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x0f\n\x07subject\x18\x04 \x01(\t\"\x81\x01\n\x0fSTSTokenRequest\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x83\x01\n\x10STSTokenResponse\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07subject\x18\x03 \x01(\t\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x11\n\ttask_name\x18\x05 \x01(\t\"F\n\x13RevocationsResponse\x12\x16\n\x0erevocation_ids\x18\x01 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x02 \x03(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04*7\n\nEgressMode\x12\x14\n\x10\x45GRESS_MODE_HTTP\x10\x00\x12\x13\n\x0f\x45GRESS_MODE_TCP\x10\x01*\\\n\x12ResponseInspection\x12 \n\x1cRESPONSE_INSPECTION_BUFFERED\x10\x00\x12$\n RESPONSE_INSPECTION_REQUEST_ONLY\x10\x01\x42\x1bZ\x19github.com/google/sam/apib\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'sam_pb2', globals()) @@ -31,16 +31,22 @@ _COMMANDBACKEND_ENVENTRY._serialized_options = b'8\001' _SERVICEANNOUNCE_LABELSENTRY._options = None _SERVICEANNOUNCE_LABELSENTRY._serialized_options = b'8\001' + _ENROLLMENTREQUEST_LABELSENTRY._options = None + _ENROLLMENTREQUEST_LABELSENTRY._serialized_options = b'8\001' + _ENROLLEDNODE_LABELSENTRY._options = None + _ENROLLEDNODE_LABELSENTRY._serialized_options = b'8\001' + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._options = None + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_options = b'8\001' _PEEREVIDENCERESPONSE_LABELSENTRY._options = None _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_options = b'8\001' - _ENROLLMENTSTATUS._serialized_start=7548 - _ENROLLMENTSTATUS._serialized_end=7696 - _SERVICETYPE._serialized_start=7699 - _SERVICETYPE._serialized_end=7839 - _EGRESSMODE._serialized_start=7841 - _EGRESSMODE._serialized_end=7896 - _RESPONSEINSPECTION._serialized_start=7898 - _RESPONSEINSPECTION._serialized_end=7990 + _ENROLLMENTSTATUS._serialized_start=10054 + _ENROLLMENTSTATUS._serialized_end=10202 + _SERVICETYPE._serialized_start=10205 + _SERVICETYPE._serialized_end=10345 + _EGRESSMODE._serialized_start=10347 + _EGRESSMODE._serialized_end=10402 + _RESPONSEINSPECTION._serialized_start=10404 + _RESPONSEINSPECTION._serialized_end=10496 _AUTHFRAME._serialized_start=86 _AUTHFRAME._serialized_end=138 _AUTHRESPONSE._serialized_start=140 @@ -135,32 +141,62 @@ _TOKENREVOKEREQUEST._serialized_end=5471 _TOKENREVOKERESPONSE._serialized_start=5473 _TOKENREVOKERESPONSE._serialized_end=5526 - _IDENTITYEVIDENCERESPONSE._serialized_start=5529 - _IDENTITYEVIDENCERESPONSE._serialized_end=5757 - _PEEREVIDENCERESPONSE._serialized_start=5760 - _PEEREVIDENCERESPONSE._serialized_end=6080 + _BOOTSTRAPTOKENCREATEREQUEST._serialized_start=5529 + _BOOTSTRAPTOKENCREATEREQUEST._serialized_end=5679 + _BOOTSTRAPTOKENCREATERESPONSE._serialized_start=5682 + _BOOTSTRAPTOKENCREATERESPONSE._serialized_end=5820 + _BOOTSTRAPTOKEN._serialized_start=5823 + _BOOTSTRAPTOKEN._serialized_end=6122 + _BOOTSTRAPTOKENLISTRESPONSE._serialized_start=6124 + _BOOTSTRAPTOKENLISTRESPONSE._serialized_end=6192 + _ENROLLMENTREQUEST._serialized_start=6195 + _ENROLLMENTREQUEST._serialized_end=6525 + _ENROLLMENTREQUEST_LABELSENTRY._serialized_start=621 + _ENROLLMENTREQUEST_LABELSENTRY._serialized_end=666 + _ENROLLMENTREQUESTLISTRESPONSE._serialized_start=6527 + _ENROLLMENTREQUESTLISTRESPONSE._serialized_end=6603 + _USER._serialized_start=6605 + _USER._serialized_end=6717 + _ENROLLEDNODE._serialized_start=6720 + _ENROLLEDNODE._serialized_end=7069 + _ENROLLEDNODE_LABELSENTRY._serialized_start=621 + _ENROLLEDNODE_LABELSENTRY._serialized_end=666 + _ROUTERLEASE._serialized_start=7072 + _ROUTERLEASE._serialized_end=7268 + _NODESERVICES._serialized_start=7270 + _NODESERVICES._serialized_end=7372 + _ADMINSTATUSRESPONSE._serialized_start=7375 + _ADMINSTATUSRESPONSE._serialized_end=7802 + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_start=7730 + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_end=7802 + _USERSTATUSRESPONSE._serialized_start=7805 + _USERSTATUSRESPONSE._serialized_end=8032 + _IDENTITYEVIDENCERESPONSE._serialized_start=8035 + _IDENTITYEVIDENCERESPONSE._serialized_end=8263 + _PEEREVIDENCERESPONSE._serialized_start=8266 + _PEEREVIDENCERESPONSE._serialized_end=8586 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_start=621 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_end=666 - _MEMBERCREDENTIAL._serialized_start=6083 - _MEMBERCREDENTIAL._serialized_end=6339 - _TRUSTEDSIGNINGKEY._serialized_start=6341 - _TRUSTEDSIGNINGKEY._serialized_end=6430 - _OIDCSESSION._serialized_start=6432 - _OIDCSESSION._serialized_end=6521 - _TASKAUTHORIZATIONRULE._serialized_start=6524 - _TASKAUTHORIZATIONRULE._serialized_end=6665 - _TASKRULE._serialized_start=6667 - _TASKRULE._serialized_end=6793 - _TASKOPERATION._serialized_start=6795 - _TASKOPERATION._serialized_end=6910 - _TOKENEXCHANGEREQUEST._serialized_start=6913 - _TOKENEXCHANGEREQUEST._serialized_end=7078 - _TOKENEXCHANGERESPONSE._serialized_start=7080 - _TOKENEXCHANGERESPONSE._serialized_end=7207 - _STSTOKENREQUEST._serialized_start=7210 - _STSTOKENREQUEST._serialized_end=7339 - _STSTOKENRESPONSE._serialized_start=7342 - _STSTOKENRESPONSE._serialized_end=7473 - _REVOCATIONSRESPONSE._serialized_start=7475 - _REVOCATIONSRESPONSE._serialized_end=7545 + _MEMBERCREDENTIAL._serialized_start=8589 + _MEMBERCREDENTIAL._serialized_end=8845 + _TRUSTEDSIGNINGKEY._serialized_start=8847 + _TRUSTEDSIGNINGKEY._serialized_end=8936 + _OIDCSESSION._serialized_start=8938 + _OIDCSESSION._serialized_end=9027 + _TASKAUTHORIZATIONRULE._serialized_start=9030 + _TASKAUTHORIZATIONRULE._serialized_end=9171 + _TASKRULE._serialized_start=9173 + _TASKRULE._serialized_end=9299 + _TASKOPERATION._serialized_start=9301 + _TASKOPERATION._serialized_end=9416 + _TOKENEXCHANGEREQUEST._serialized_start=9419 + _TOKENEXCHANGEREQUEST._serialized_end=9584 + _TOKENEXCHANGERESPONSE._serialized_start=9586 + _TOKENEXCHANGERESPONSE._serialized_end=9713 + _STSTOKENREQUEST._serialized_start=9716 + _STSTOKENREQUEST._serialized_end=9845 + _STSTOKENRESPONSE._serialized_start=9848 + _STSTOKENRESPONSE._serialized_end=9979 + _REVOCATIONSRESPONSE._serialized_start=9981 + _REVOCATIONSRESPONSE._serialized_end=10051 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi b/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi index 613bfc43..728b0e17 100644 --- a/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi +++ b/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi @@ -29,6 +29,31 @@ class AWSAssumeRole(_message.Message): session_policy: str def __init__(self, role_arn: _Optional[str] = ..., session_policy: _Optional[str] = ...) -> None: ... +class AdminStatusResponse(_message.Message): + __slots__ = ["active_routers", "bootstrap_tokens", "enrolled_nodes", "enrollment_requests", "node_catalog", "policy", "users"] + class NodeCatalogEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: NodeServices + def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[NodeServices, _Mapping]] = ...) -> None: ... + ACTIVE_ROUTERS_FIELD_NUMBER: _ClassVar[int] + BOOTSTRAP_TOKENS_FIELD_NUMBER: _ClassVar[int] + ENROLLED_NODES_FIELD_NUMBER: _ClassVar[int] + ENROLLMENT_REQUESTS_FIELD_NUMBER: _ClassVar[int] + NODE_CATALOG_FIELD_NUMBER: _ClassVar[int] + POLICY_FIELD_NUMBER: _ClassVar[int] + USERS_FIELD_NUMBER: _ClassVar[int] + active_routers: _containers.RepeatedCompositeFieldContainer[RouterLease] + bootstrap_tokens: _containers.RepeatedCompositeFieldContainer[BootstrapToken] + enrolled_nodes: _containers.RepeatedCompositeFieldContainer[EnrolledNode] + enrollment_requests: _containers.RepeatedCompositeFieldContainer[EnrollmentRequest] + node_catalog: _containers.MessageMap[str, NodeServices] + policy: PolicyConfig + users: _containers.RepeatedCompositeFieldContainer[User] + def __init__(self, users: _Optional[_Iterable[_Union[User, _Mapping]]] = ..., active_routers: _Optional[_Iterable[_Union[RouterLease, _Mapping]]] = ..., enrolled_nodes: _Optional[_Iterable[_Union[EnrolledNode, _Mapping]]] = ..., enrollment_requests: _Optional[_Iterable[_Union[EnrollmentRequest, _Mapping]]] = ..., bootstrap_tokens: _Optional[_Iterable[_Union[BootstrapToken, _Mapping]]] = ..., policy: _Optional[_Union[PolicyConfig, _Mapping]] = ..., node_catalog: _Optional[_Mapping[str, NodeServices]] = ...) -> None: ... + class AuthFrame(_message.Message): __slots__ = ["biscuit", "target_service"] BISCUIT_FIELD_NUMBER: _ClassVar[int] @@ -90,6 +115,66 @@ class BootstrapEnrollResponse(_message.Message): status: EnrollmentStatus def __init__(self, status: _Optional[_Union[EnrollmentStatus, str]] = ..., biscuit_token: _Optional[bytes] = ..., poll_interval_seconds: _Optional[int] = ..., error_message: _Optional[str] = ..., control_plane_public_key: _Optional[bytes] = ..., router_addresses: _Optional[_Iterable[str]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... +class BootstrapToken(_message.Message): + __slots__ = ["autonomous_recovery", "create_time", "description", "expire_time", "id", "max_usages", "owner_id", "revoke_time", "role", "usages_count"] + AUTONOMOUS_RECOVERY_FIELD_NUMBER: _ClassVar[int] + CREATE_TIME_FIELD_NUMBER: _ClassVar[int] + DESCRIPTION_FIELD_NUMBER: _ClassVar[int] + EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + MAX_USAGES_FIELD_NUMBER: _ClassVar[int] + OWNER_ID_FIELD_NUMBER: _ClassVar[int] + REVOKE_TIME_FIELD_NUMBER: _ClassVar[int] + ROLE_FIELD_NUMBER: _ClassVar[int] + USAGES_COUNT_FIELD_NUMBER: _ClassVar[int] + autonomous_recovery: bool + create_time: _timestamp_pb2.Timestamp + description: str + expire_time: _timestamp_pb2.Timestamp + id: str + max_usages: int + owner_id: str + revoke_time: _timestamp_pb2.Timestamp + role: str + usages_count: int + def __init__(self, id: _Optional[str] = ..., role: _Optional[str] = ..., owner_id: _Optional[str] = ..., max_usages: _Optional[int] = ..., usages_count: _Optional[int] = ..., description: _Optional[str] = ..., create_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., revoke_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., autonomous_recovery: bool = ...) -> None: ... + +class BootstrapTokenCreateRequest(_message.Message): + __slots__ = ["autonomous_recovery", "description", "max_usages", "owner_id", "role", "ttl_hours"] + AUTONOMOUS_RECOVERY_FIELD_NUMBER: _ClassVar[int] + DESCRIPTION_FIELD_NUMBER: _ClassVar[int] + MAX_USAGES_FIELD_NUMBER: _ClassVar[int] + OWNER_ID_FIELD_NUMBER: _ClassVar[int] + ROLE_FIELD_NUMBER: _ClassVar[int] + TTL_HOURS_FIELD_NUMBER: _ClassVar[int] + autonomous_recovery: bool + description: str + max_usages: int + owner_id: str + role: str + ttl_hours: int + def __init__(self, role: _Optional[str] = ..., owner_id: _Optional[str] = ..., ttl_hours: _Optional[int] = ..., max_usages: _Optional[int] = ..., description: _Optional[str] = ..., autonomous_recovery: bool = ...) -> None: ... + +class BootstrapTokenCreateResponse(_message.Message): + __slots__ = ["expire_time", "id", "owner_id", "role", "token"] + EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + OWNER_ID_FIELD_NUMBER: _ClassVar[int] + ROLE_FIELD_NUMBER: _ClassVar[int] + TOKEN_FIELD_NUMBER: _ClassVar[int] + expire_time: _timestamp_pb2.Timestamp + id: str + owner_id: str + role: str + token: str + def __init__(self, id: _Optional[str] = ..., token: _Optional[str] = ..., role: _Optional[str] = ..., owner_id: _Optional[str] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + +class BootstrapTokenListResponse(_message.Message): + __slots__ = ["tokens"] + TOKENS_FIELD_NUMBER: _ClassVar[int] + tokens: _containers.RepeatedCompositeFieldContainer[BootstrapToken] + def __init__(self, tokens: _Optional[_Iterable[_Union[BootstrapToken, _Mapping]]] = ...) -> None: ... + class CommandBackend(_message.Message): __slots__ = ["command", "env"] class EnvEntry(_message.Message): @@ -216,6 +301,70 @@ class EnrollResponse(_message.Message): router_addresses: _containers.RepeatedScalarFieldContainer[str] def __init__(self, biscuit_token: _Optional[bytes] = ..., error_message: _Optional[str] = ..., control_plane_public_key: _Optional[bytes] = ..., router_addresses: _Optional[_Iterable[str]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... +class EnrolledNode(_message.Message): + __slots__ = ["autonomous_recovery", "banned", "claims_json", "enroll_time", "enrollment_type", "expire_time", "labels", "owner_id", "peer_id", "role"] + class LabelsEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + AUTONOMOUS_RECOVERY_FIELD_NUMBER: _ClassVar[int] + BANNED_FIELD_NUMBER: _ClassVar[int] + CLAIMS_JSON_FIELD_NUMBER: _ClassVar[int] + ENROLLMENT_TYPE_FIELD_NUMBER: _ClassVar[int] + ENROLL_TIME_FIELD_NUMBER: _ClassVar[int] + EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + OWNER_ID_FIELD_NUMBER: _ClassVar[int] + PEER_ID_FIELD_NUMBER: _ClassVar[int] + ROLE_FIELD_NUMBER: _ClassVar[int] + autonomous_recovery: bool + banned: bool + claims_json: str + enroll_time: _timestamp_pb2.Timestamp + enrollment_type: str + expire_time: _timestamp_pb2.Timestamp + labels: _containers.ScalarMap[str, str] + owner_id: str + peer_id: str + role: str + def __init__(self, peer_id: _Optional[str] = ..., role: _Optional[str] = ..., enrollment_type: _Optional[str] = ..., claims_json: _Optional[str] = ..., owner_id: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., enroll_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., banned: bool = ..., autonomous_recovery: bool = ...) -> None: ... + +class EnrollmentRequest(_message.Message): + __slots__ = ["create_time", "id", "labels", "peer_id", "resolve_time", "resolved_by", "status", "token_id"] + class LabelsEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + CREATE_TIME_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + PEER_ID_FIELD_NUMBER: _ClassVar[int] + RESOLVED_BY_FIELD_NUMBER: _ClassVar[int] + RESOLVE_TIME_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + TOKEN_ID_FIELD_NUMBER: _ClassVar[int] + create_time: _timestamp_pb2.Timestamp + id: str + labels: _containers.ScalarMap[str, str] + peer_id: str + resolve_time: _timestamp_pb2.Timestamp + resolved_by: str + status: EnrollmentStatus + token_id: str + def __init__(self, id: _Optional[str] = ..., peer_id: _Optional[str] = ..., token_id: _Optional[str] = ..., status: _Optional[_Union[EnrollmentStatus, str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., create_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., resolve_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., resolved_by: _Optional[str] = ...) -> None: ... + +class EnrollmentRequestListResponse(_message.Message): + __slots__ = ["requests"] + REQUESTS_FIELD_NUMBER: _ClassVar[int] + requests: _containers.RepeatedCompositeFieldContainer[EnrollmentRequest] + def __init__(self, requests: _Optional[_Iterable[_Union[EnrollmentRequest, _Mapping]]] = ...) -> None: ... + class ExtProc(_message.Message): __slots__ = ["allow_mode_override", "ca", "client_certificate", "failure_mode_allow", "max_buffered_bytes", "message_timeout", "processing_mode", "target"] ALLOW_MODE_OVERRIDE_FIELD_NUMBER: _ClassVar[int] @@ -369,6 +518,14 @@ class NodeCatalogReport(_message.Message): services: _containers.RepeatedCompositeFieldContainer[ServiceInfo] def __init__(self, services: _Optional[_Iterable[_Union[ServiceInfo, _Mapping]]] = ...) -> None: ... +class NodeServices(_message.Message): + __slots__ = ["report_time", "services"] + REPORT_TIME_FIELD_NUMBER: _ClassVar[int] + SERVICES_FIELD_NUMBER: _ClassVar[int] + report_time: _timestamp_pb2.Timestamp + services: _containers.RepeatedCompositeFieldContainer[ServiceInfo] + def __init__(self, services: _Optional[_Iterable[_Union[ServiceInfo, _Mapping]]] = ..., report_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + class OIDCFederation(_message.Message): __slots__ = ["audience", "impersonate", "scopes", "token_endpoint"] AUDIENCE_FIELD_NUMBER: _ClassVar[int] @@ -496,6 +653,22 @@ class RevocationsResponse(_message.Message): revocation_ids: _containers.RepeatedScalarFieldContainer[str] def __init__(self, revocation_ids: _Optional[_Iterable[str]] = ..., banned_peer_ids: _Optional[_Iterable[str]] = ...) -> None: ... +class RouterLease(_message.Message): + __slots__ = ["addresses", "connected_peers", "dht_size", "expire_time", "last_renewal_time", "peer_id"] + ADDRESSES_FIELD_NUMBER: _ClassVar[int] + CONNECTED_PEERS_FIELD_NUMBER: _ClassVar[int] + DHT_SIZE_FIELD_NUMBER: _ClassVar[int] + EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] + LAST_RENEWAL_TIME_FIELD_NUMBER: _ClassVar[int] + PEER_ID_FIELD_NUMBER: _ClassVar[int] + addresses: _containers.RepeatedScalarFieldContainer[str] + connected_peers: _containers.RepeatedScalarFieldContainer[str] + dht_size: int + expire_time: _timestamp_pb2.Timestamp + last_renewal_time: _timestamp_pb2.Timestamp + peer_id: str + def __init__(self, peer_id: _Optional[str] = ..., addresses: _Optional[_Iterable[str]] = ..., last_renewal_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., connected_peers: _Optional[_Iterable[str]] = ..., dht_size: _Optional[int] = ...) -> None: ... + class RouterLeaseRequest(_message.Message): __slots__ = ["addresses", "biscuit", "challenge_signature", "challenge_unix_ms", "connected_peers", "dht_size", "peer_id"] ADDRESSES_FIELD_NUMBER: _ClassVar[int] @@ -695,6 +868,34 @@ class TrustedSigningKey(_message.Message): receive_time: _timestamp_pb2.Timestamp def __init__(self, public_key: _Optional[bytes] = ..., receive_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... +class User(_message.Message): + __slots__ = ["create_time", "email", "id", "issuer", "role"] + CREATE_TIME_FIELD_NUMBER: _ClassVar[int] + EMAIL_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + ISSUER_FIELD_NUMBER: _ClassVar[int] + ROLE_FIELD_NUMBER: _ClassVar[int] + create_time: _timestamp_pb2.Timestamp + email: str + id: str + issuer: str + role: str + def __init__(self, id: _Optional[str] = ..., issuer: _Optional[str] = ..., email: _Optional[str] = ..., role: _Optional[str] = ..., create_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + +class UserStatusResponse(_message.Message): + __slots__ = ["active_routers", "bootstrap_tokens", "enrolled_nodes", "policy", "user"] + ACTIVE_ROUTERS_FIELD_NUMBER: _ClassVar[int] + BOOTSTRAP_TOKENS_FIELD_NUMBER: _ClassVar[int] + ENROLLED_NODES_FIELD_NUMBER: _ClassVar[int] + POLICY_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + active_routers: _containers.RepeatedCompositeFieldContainer[RouterLease] + bootstrap_tokens: _containers.RepeatedCompositeFieldContainer[BootstrapToken] + enrolled_nodes: _containers.RepeatedCompositeFieldContainer[EnrolledNode] + policy: PolicyConfig + user: User + def __init__(self, user: _Optional[_Union[User, _Mapping]] = ..., enrolled_nodes: _Optional[_Iterable[_Union[EnrolledNode, _Mapping]]] = ..., bootstrap_tokens: _Optional[_Iterable[_Union[BootstrapToken, _Mapping]]] = ..., active_routers: _Optional[_Iterable[_Union[RouterLease, _Mapping]]] = ..., policy: _Optional[_Union[PolicyConfig, _Mapping]] = ...) -> None: ... + class EnrollmentStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = [] diff --git a/sdk/python/tests/test_authorizer.py b/sdk/python/tests/test_authorizer.py index 8341e3b8..b436181e 100644 --- a/sdk/python/tests/test_authorizer.py +++ b/sdk/python/tests/test_authorizer.py @@ -266,3 +266,41 @@ def test_attenuate_biscuit_narrows_authority_across_hops(): [CP_KEY], ) + +def test_appended_blocks_carrying_anything_but_one_tar_block_fact_are_refused(): + """An appended block may carry exactly one tar_block fact. Anything else a + holder writes there (a rule, a check, a second fact) is refused before the + Datalog runs, whatever the authority block grants.""" + from agent_mesh.tar import encode_tar_block_fact + + root = node_token(CALLER, ["granted_service_all_types(true)", "target_unrestricted(true)"]) + tar_fact = encode_tar_block_fact(sam_pb2.TaskAuthorizationRule(name="hop", rules=[sam_pb2.TaskRule(allowed_services=["mcp://calc"])])) + + def append(build) -> bytes: + token = ba.Biscuit.from_bytes(root, CP.public_key) + bb = ba.BlockBuilder() + build(bb) + return token.append(bb).to_bytes() + + def rule_only(bb): + bb.add_rule(ba.Rule('role("sam:role:router") <- role("sam:role:node")')) + + def check_only(bb): + bb.add_check(ba.Check("check if true")) + + def extra_fact(bb): + bb.add_fact(ba.Fact(tar_fact)) + bb.add_fact(ba.Fact(f'node("{CALLER}")')) + + def rule_beside_tar_block(bb): + bb.add_fact(ba.Fact(tar_fact)) + bb.add_rule(ba.Rule('granted_service_all_types(true) <- role("sam:role:node")')) + + for build in (rule_only, check_only, extra_fact, rule_beside_tar_block): + with pytest.raises(AuthorizationError, match="tar_block"): + authorize_caller(request(append(build)), options([])) + + # The well-formed block is the control. + verified = authorize_caller(request(append(lambda bb: bb.add_fact(ba.Fact(tar_fact)))), options([])) + assert len(verified.task_rules) == 1 + diff --git a/site/content/docs/concepts/authorization.md b/site/content/docs/concepts/authorization.md index 544fd1e9..1f7302fa 100644 --- a/site/content/docs/concepts/authorization.md +++ b/site/content/docs/concepts/authorization.md @@ -202,8 +202,8 @@ permissions: - On `/mcp` and `/v1/*` (or via RFC 8693 `POST /oauth/token` and Envoy `ext_authz` / `ext_proc`), the caller presents its own platform JWT (OIDC ID - token, Kubernetes projected SA JWT, SPIFFE JWT-SVID, or Istio mTLS XFCC - identity) or a task-attenuated Biscuit. + token, Kubernetes projected SA JWT, or SPIFFE JWT-SVID) or a task-attenuated + Biscuit. - `sam-node` exchanges platform JWTs via `POST /token/exchange` into a **Delegated Session Biscuit** carrying the caller's own `user()`, `email()`, `group()`, and `role()` facts, bound to the node via `client_peer_id()` and diff --git a/site/content/docs/contributing/security-architecture.md b/site/content/docs/contributing/security-architecture.md index 9ad1ff69..d0c905a6 100644 --- a/site/content/docs/contributing/security-architecture.md +++ b/site/content/docs/contributing/security-architecture.md @@ -79,7 +79,7 @@ SAM separates caller attestation from task authorization: | Layer | Question answered | Primitive | Role in SAM | | :--- | :--- | :--- | :--- | -| **1. Workload / Subject & Channel Attestation** | *"Which workload or user initiated this request, and through which node is it travelling?"* | **OIDC ID tokens**, **Kubernetes projected SA JWTs**, **GCE/Cloud Run identity tokens**, **SPIFFE JWT-SVIDs**, or **Istio XFCC**. | Verified at `POST /register` (node enrollment) or `POST /token/exchange` (caller delegation) to mint a Biscuit bound to the transport channel (`client_peer_id`, `actor_node`). | +| **1. Workload / Subject & Channel Attestation** | *"Which workload or user initiated this request, and through which node is it travelling?"* | **OIDC ID tokens**, **Kubernetes projected SA JWTs**, **GCE/Cloud Run identity tokens**, or **SPIFFE JWT-SVIDs**. | Verified at `POST /register` (node enrollment) or `POST /token/exchange` (caller delegation) to mint a Biscuit bound to the transport channel (`client_peer_id`, `actor_node`). | | **2. Task / Session Authorization (TAR)** | *"What subset of standing permissions may this specific task or sub-agent hop exercise right now?"* | **SAM Task Biscuit** (Block 0 Authority + appended `tar_block` blocks carrying `api.TaskAuthorizationRule`). | Attenuated offline across hops, enforced at every SAM Policy Enforcement Point (PEP), and translated into downscoped upstream cloud credentials by `CloudTokenExchanger` at egress. | --- @@ -366,11 +366,13 @@ as a standard OIDC issuer: 1. Envoy `ext_authz` (`/ext_authz` and `/envoy.service.auth.v3.Authorization/Check`) evaluates standing Datalog - policy and `tar_block` rules on incoming Envoy `CheckRequest` calls, accepts - verified SPIFFE IDs from `AttributeContext.Source.Principal` or - `X-Forwarded-Client-Cert` (XFCC) on trusted proxy listeners, and injects - brokered upstream credentials (`Authorization: Bearer ...`, - `X-Sam-Principal`, `X-Sam-Task-Id`). + policy and `tar_block` rules on incoming Envoy `CheckRequest` calls. The + caller presents a Biscuit (`X-Sam-Biscuit`, or a Bearer value in + `X-Sam-Authentication` or `Authorization`) or a platform JWT, which + `sam-node` exchanges at the control plane into a delegated Biscuit. On `OK` + it returns `X-Sam-Biscuit`, `X-Sam-Principal`, `X-Sam-Roles`, + `X-Sam-Task-Id` and, for `egress://` targets with a credential broker, the + brokered upstream `Authorization` header. 2. Envoy `ext_proc` (`/envoy.service.ext_proc.v3.ExternalProcessor/Process`) inspects buffered JSON-RPC request bodies so `operation.allowed_tools` on MCP `tools/call` is enforced before injecting the upstream credential. @@ -463,8 +465,8 @@ projected service account JWT) to authenticate to a local or cluster #### Blueprint 3: `agentgateway` / Istio service mesh -Workloads authenticate to `agentgateway` or Istio via mTLS SPIFFE XFCC or a -Task Biscuit; the gateway calls `sam-node` over `ext_authz`, `ext_proc`, or RFC +Workloads authenticate to `agentgateway` or Istio with a platform JWT (for +example a SPIFFE JWT-SVID) or a Task Biscuit; the gateway calls `sam-node` over `ext_authz`, `ext_proc`, or RFC 8693 `/oauth/token` for local policy enforcement and routes cross-cluster MCP, A2A, and cloud egress calls through `sam-node`. diff --git a/site/content/docs/guides/headless-enrollment.md b/site/content/docs/guides/headless-enrollment.md index 82c16c0f..67c39e03 100644 --- a/site/content/docs/guides/headless-enrollment.md +++ b/site/content/docs/guides/headless-enrollment.md @@ -124,7 +124,7 @@ curl -fsS -X POST https://mesh.example.com/admin/bootstrap-tokens \ ``` ```json -{"id":"62e92ffca…","token":"sam-bt-72fb0175788dee0…","role":"sam:role:node","expires_at":"2026-09-20T15:00:00Z"} +{"id":"62e92ffca…","token":"sam-bt-72fb0175788dee0…","role":"sam:role:node","expire_time":"2026-09-20T15:00:00Z"} ``` The plaintext `token` is shown once. The control plane keeps only its hash. diff --git a/site/content/docs/preview/agent-architecture.md b/site/content/docs/preview/agent-architecture.md index 42a785f0..e909adee 100644 --- a/site/content/docs/preview/agent-architecture.md +++ b/site/content/docs/preview/agent-architecture.md @@ -56,7 +56,7 @@ SAM separates the two layers: | Layer | Question answered | Primitive | |---|---|---| -| **Subject & channel attestation** | *Which user or workload initiated this request, and through which node?* | OIDC ID token, Kubernetes projected SA JWT, SPIFFE JWT-SVID, or Istio mTLS identity (`source.principal` / XFCC), exchanged into a Biscuit bound to the channel (`client_peer_id`, `actor_node`). | +| **Subject & channel attestation** | *Which user or workload initiated this request, and through which node?* | OIDC ID token, Kubernetes projected SA JWT, or SPIFFE JWT-SVID, exchanged into a Biscuit bound to the channel (`client_peer_id`, `actor_node`). | | **Task authorization (TAR)** | *What subset of standing permissions may this task or sub-agent hop exercise?* | Appended `tar_block` blocks carrying `api.TaskAuthorizationRule`, intersected across hops and translated into downscoped cloud credentials at egress. | ## Separation of responsibilities @@ -158,12 +158,12 @@ Where a cluster already runs Envoy, Istio, or `agentgateway`, `sam-node` acts as their external Policy Decision Point and Token Service over three standard interfaces: -1. **Envoy `ext_authz` (`--ext-authz-addr`):** evaluates standing policy and - `tar_block` chains on incoming `CheckRequest` calls, accepts verified - SPIFFE principals from `AttributeContext.Source.Principal` or - `X-Forwarded-Client-Cert` (XFCC) on trusted proxy listeners, and injects - brokered upstream credentials (`Authorization: Bearer ...`, - `X-Sam-Principal`, `X-Sam-Task-Id`). +1. **Envoy `ext_authz` (on the local API listeners):** evaluates standing + policy and `tar_block` chains on incoming `CheckRequest` calls. The caller + presents a Biscuit, or a platform JWT that `sam-node` exchanges at the + control plane into a delegated Biscuit. On `OK` it returns `X-Sam-Biscuit`, + `X-Sam-Principal`, `X-Sam-Roles`, `X-Sam-Task-Id` and, for `egress://` + targets with a credential broker, the brokered `Authorization` header. 2. **Envoy `ext_proc` (`envoy.service.ext_proc.v3.ExternalProcessor`):** the body-aware counterpart to `ext_authz`. Because MCP tool names travel inside the JSON-RPC request body (`params.name`), `ext_proc` inspects the buffered diff --git a/site/content/docs/preview/sandboxed-agents.md b/site/content/docs/preview/sandboxed-agents.md index ca8ad265..1bd3f34e 100644 --- a/site/content/docs/preview/sandboxed-agents.md +++ b/site/content/docs/preview/sandboxed-agents.md @@ -127,15 +127,14 @@ In clusters that already route agent traffic through **`agentgateway`**, continues to flow through those proxies while `sam-node` serves as the Policy Decision Point and Token Service: -- **Envoy `ext_authz` (`--ext-authz-addr`) and `ext_proc`:** +- **Envoy `ext_authz` and `ext_proc` (on the local API listeners):** Istio `AuthorizationPolicy (action: CUSTOM)` or `agentgateway` calls - `sam-node`. When no Bearer token is present on a trusted proxy listener, - `sam-node` reads the caller's verified SPIFFE ID from - `AttributeContext.Source.Principal` or `X-Forwarded-Client-Cert` (XFCC), - exchanges it into a Delegated Session Biscuit, evaluates the standing Datalog - policy and any `TaskAuthorizationRule` chain (including MCP tool names in - JSON-RPC bodies via `ext_proc`), and injects the upstream credential into - `Authorization` before the gateway forwards the request. + `sam-node` with the caller's Biscuit, or with a platform JWT that + `sam-node` exchanges at the control plane into a delegated Biscuit. + `sam-node` evaluates the standing Datalog policy and any + `TaskAuthorizationRule` chain (including MCP tool names in JSON-RPC bodies + via `ext_proc`), and for `egress://` targets injects the brokered upstream + credential into `Authorization` before the gateway forwards the request. - **RFC 8693 backend token exchange (`POST /oauth/token`):** `agentgateway`'s built-in RFC 8693 token exchange policy can point directly at `http://sam-node:8080/oauth/token` to exchange workload JWTs or narrow diff --git a/site/content/docs/reference/control-plane.md b/site/content/docs/reference/control-plane.md index 2641c08d..710bdd19 100644 --- a/site/content/docs/reference/control-plane.md +++ b/site/content/docs/reference/control-plane.md @@ -89,16 +89,19 @@ names. Every instant in a response (`expire_time` and the like) is a ### Admin -All routes require `Authorization: Bearer ` and use JSON. +All routes require `Authorization: Bearer `. Request and +response bodies are protojson of the messages named below, with proto field +names; unknown fields are rejected. | Route | Purpose | |---|---| -| `GET /admin/status` | Everything the console shows: routers, nodes, enrollment requests, tokens, users, and the policy as JSON. | -| `GET /admin/policy` | The mesh policy as protojson of `PolicyConfig`, the same document `POST /policies` takes. | -| `POST /policies`, `PUT /policies` | Replace the mesh policy. The JSON body is protojson of `PolicyConfig`. Unknown fields are rejected, so a misspelt grant fails instead of being dropped. | -| `POST /admin/bootstrap-tokens` | Mint a token. Body: `role` (required), `ttl_hours` (default 24), `max_usages` (default 1), `description`, `autonomous_recovery` (default false). Returns `201` with `id`, `token` (shown once), `role`, `expires_at`. | -| `DELETE /admin/bootstrap-tokens/{id}` | Revoke a token. Can be repeated. The token stays in the list, marked as revoked. | -| `GET /admin/enrollments` | Pending bootstrap enrollment requests. | +| `GET /admin/status` | `AdminStatusResponse`: everything the console shows: routers, nodes, enrollment requests, tokens, users, the policy and the node service catalog. | +| `GET /admin/policy` | The mesh policy as `PolicyConfig`, the same document `POST /policies` takes. | +| `POST /policies`, `PUT /policies` | Replace the mesh policy. The body is `PolicyConfig`, so a misspelt grant fails instead of being dropped. | +| `GET /admin/bootstrap-tokens` | `BootstrapTokenListResponse`: every token, including revoked and expired ones. | +| `POST /admin/bootstrap-tokens` | Mint a token. Body `BootstrapTokenCreateRequest`: `role` (required), `ttl_hours` (default 24), `max_usages` (default 1), `description`, `autonomous_recovery` (default false). Returns `201` with `BootstrapTokenCreateResponse`: `id`, `token` (shown once), `role`, `expire_time`. | +| `DELETE /admin/bootstrap-tokens/{id}` | Revoke a token. Can be repeated. The token stays in the list with `revoke_time` set. | +| `GET /admin/enrollments` | `EnrollmentRequestListResponse`: bootstrap enrollment requests, pending and decided. | | `POST /admin/enrollments/{id}/approve` | Approve. Spends a token usage. Checks the token again, and checks the labels against the role's `allowed_labels`. | | `POST /admin/enrollments/{id}/reject` | Reject. | | `POST /admin/revoke` | Body `{"peer_id": "..."}`. Ban the node and, for an OIDC enrollment, the identity behind it. | @@ -113,8 +116,8 @@ with `403`). The console uses these routes. | Route | Purpose | |---|---| -| `GET /user/status` | The caller's enrolled nodes and tokens. | -| `POST /user/bootstrap-tokens` | Mint a token owned by the caller. `role` defaults to `sam:role:node`. Banning the owner disables the tokens they minted. | +| `GET /user/status` | `UserStatusResponse`: the caller, their enrolled nodes and tokens. For an administrator it also carries the routers and the policy. | +| `POST /user/bootstrap-tokens` | Mint a token owned by the caller; body and response as on the admin route. `role` defaults to `sam:role:node`. Banning the owner disables the tokens they minted. | | `POST /user/revoke` | Ban one of the caller's own nodes. | ## Notes diff --git a/site/content/docs/reference/node-api.md b/site/content/docs/reference/node-api.md index d41355e6..6e6e7911 100644 --- a/site/content/docs/reference/node-api.md +++ b/site/content/docs/reference/node-api.md @@ -8,9 +8,9 @@ aliases: The local API that `sam-node run` serves to agents, gateways, and scripts on the same machine or cluster. It is available on TCP (`--bind-addr`, default -`127.0.0.1:8080`), on a Unix socket (`--socket-path`, default -`/sam.sock`), and optionally on a dedicated Envoy `ext_authz` / -`ext_proc` listener (`--ext-authz-addr`). +`127.0.0.1:8080`) and on a Unix socket (`--socket-path`, default +`/sam.sock`). The Envoy `ext_authz` and `ext_proc` endpoints are +served on the same listeners. ## Authentication @@ -85,17 +85,21 @@ revocation ID in the node's local revocation cache until the token expires. ## Envoy `ext_authz` and `ext_proc` gateway integration -When `--ext-authz-addr` (`host:port` or `unix:/path`) is set (or on the local -API listener), `sam-node` serves: +On the local API listeners, `sam-node` serves: - **Envoy HTTP `ext_authz` (`/ext_authz`, `/ext_authz/*`)** and **gRPC `envoy.service.auth.v3.Authorization/Check`**: evaluates standing Datalog - policy and any `tar_block` chain on the request. On a trusted `--ext-authz-addr` - listener, if no Bearer token is present, `sam-node` extracts the caller's - verified SPIFFE principal from `AttributeContext.Source.Principal` or - `X-Forwarded-Client-Cert` (XFCC) and exchanges it into a Delegated Session - Biscuit. On `OK`, it injects `Authorization: Bearer `, - `X-Sam-Principal`, and `X-Sam-Task-Id`. + policy and any `tar_block` chain on the request. The credential is read + from `X-Sam-Biscuit`, or from a Bearer value in `X-Sam-Authentication` or + `Authorization`. A Bearer value that is a platform JWT is exchanged at the + control plane (`POST /token/exchange`) into a delegated Biscuit before + evaluation; a request with no credential is refused with `401`. The target + service is taken from `X-Sam-Target-Service` or from the `/sam///` + request path. On `OK`, the response carries `X-Sam-Biscuit`, + `X-Sam-Principal`, `X-Sam-Roles`, and, when the token is task-attenuated, + `X-Sam-Task` (the last `TaskAuthorizationRule` as protojson) and + `X-Sam-Task-Id`. For an `egress://` target with a configured credential + broker it also carries the brokered `Authorization` header for the upstream. - **Envoy gRPC `envoy.service.ext_proc.v3.ExternalProcessor/Process`**: the body-aware gateway processor. It inspects JSON-RPC bodies on MCP `tools/call` requests to enforce `operation.allowed_tools` in `TaskAuthorizationRule` diff --git a/tests/e2e/auth_flows.bats b/tests/e2e/auth_flows.bats index 2de16103..7d21f8c3 100644 --- a/tests/e2e/auth_flows.bats +++ b/tests/e2e/auth_flows.bats @@ -23,8 +23,8 @@ assert_enrolled() { echo "control plane has no enrollment for ${peer_id}" >&2 return 1 fi - [[ "$(jq -r '.EnrollmentType' <<<"${record}")" == "${enrollment_type}" ]] - [[ "$(jq -r '.Role' <<<"${record}")" == "sam:role:node" ]] + [[ "$(jq -r '.enrollment_type' <<<"${record}")" == "${enrollment_type}" ]] + [[ "$(jq -r '.role' <<<"${record}")" == "sam:role:node" ]] echo "${record}" } @@ -107,7 +107,7 @@ assert_enrolled() { local record record="$(assert_enrolled login OIDC)" echo "enrollment: ${record}" - [[ "$(jq -r '.ClaimsJSON | fromjson | .sub' <<<"${record}")" == "test-user" ]] + [[ "$(jq -r '.claims_json | fromjson | .sub' <<<"${record}")" == "test-user" ]] # The labels join declared must come back attested. /sam/identity hands back # the raw biscuit rather than decoded claims, so read the signed diff --git a/tests/e2e/lib/container_mesh.bash b/tests/e2e/lib/container_mesh.bash index de8f91d3..c561721f 100644 --- a/tests/e2e/lib/container_mesh.bash +++ b/tests/e2e/lib/container_mesh.bash @@ -246,7 +246,7 @@ if [[ -z "${MESH_HELPERS_LOADED:-}" ]]; then # peer, or nothing. mesh_enrolled_node() { local peer_id="$1" - mesh_admin_status | jq -c --arg id "${peer_id}" '.enrolled_nodes // [] | .[] | select(.PeerID == $id)' + mesh_admin_status | jq -c --arg id "${peer_id}" '.enrolled_nodes // [] | .[] | select(.peer_id == $id)' } # POST /debug/connect-peer on node ; the REST endpoint that replaced @@ -484,11 +484,11 @@ if [[ -z "${MESH_HELPERS_LOADED:-}" ]]; then local router_peer_id="" local lease_deadline=$((SECONDS + 60)) while [[ -z "${router_peer_id}" ]]; do - # active_routers is null, not [], until the first lease lands. + # active_routers is absent until the first lease lands. router_peer_id=$(docker run --rm --network "${MESH_NETWORK:-kind}" "${MESH_RUNTIME_IMAGE}" \ curl -sf --max-time 5 -H "Authorization: Bearer super-secret-admin-token" \ "http://${router_node_ip}:8080/admin/status" 2>/dev/null | - jq -r '.active_routers // [] | sort_by(.LastRenewal) | last | .PeerID // empty') + jq -r '.active_routers // [] | sort_by(.last_renewal_time) | last | .peer_id // empty') if [[ -z "${router_peer_id}" ]] && ((SECONDS >= lease_deadline)); then echo "router lease did not reach the control plane within 60s" >&2 return 1 diff --git a/tests/integration/enroll_status_polling_test.go b/tests/integration/enroll_status_polling_test.go index 7ea1ebe6..e88dbb50 100644 --- a/tests/integration/enroll_status_polling_test.go +++ b/tests/integration/enroll_status_polling_test.go @@ -33,6 +33,7 @@ import ( "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-msgio" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -193,15 +194,15 @@ func TestEnrollStatusPollingCollectsBiscuit(t *testing.T) { time.Sleep(50 * time.Millisecond) continue } - var enrollList []storage.EnrollmentRequest - if err := json.NewDecoder(listResp.Body).Decode(&enrollList); err != nil { - _ = listResp.Body.Close() + listBody, readErr := io.ReadAll(listResp.Body) + _ = listResp.Body.Close() + enrollList := &api.EnrollmentRequestListResponse{} + if readErr != nil || protojson.Unmarshal(listBody, enrollList) != nil { time.Sleep(50 * time.Millisecond) continue } - _ = listResp.Body.Close() - if len(enrollList) == 1 { - reqID = enrollList[0].ID + if len(enrollList.GetRequests()) == 1 { + reqID = enrollList.GetRequests()[0].GetId() break } time.Sleep(50 * time.Millisecond) diff --git a/tests/integration/login_test.go b/tests/integration/login_test.go index 28b7c5b1..91e1340a 100644 --- a/tests/integration/login_test.go +++ b/tests/integration/login_test.go @@ -16,6 +16,7 @@ package integration_test import ( "bytes" + "context" "encoding/json" "fmt" "os" @@ -26,6 +27,7 @@ import ( "github.com/google/sam/api" "github.com/google/sam/internal/node" + "github.com/google/sam/internal/storage" ) // These tests cover how a node comes to hold, keep and lose its mesh @@ -60,9 +62,14 @@ func startMesh(t *testing.T, dir, oidcURL string, mintToken func(map[string]inte t.Helper() cpPort, cleanup := startControlPlaneAndRouter(t, dir, oidcURL, mintToken, meshPolicyFile(t, dir)) t.Cleanup(cleanup) + meshDirs[cpPort] = dir return cpPort, fmt.Sprintf("http://127.0.0.1:%d", cpPort) } +// meshDirs maps a control plane started by startMesh to its directory, so a +// test can open the control plane's store (see enrolledAs). +var meshDirs = map[int]string{} + // nodeHome is the environment of a node whose files live under home, and // the store that environment resolves to. func nodeHome(home string) ([]string, string) { @@ -101,26 +108,42 @@ func join(t *testing.T, nodeBin string, env []string, cpURL string, extra ...str } // enrolledAs fails unless the control plane holds an enrollment for peerID -// made by subject, and returns it. +// made by subject, and returns the biscuit it issued. The operator plane +// never carries a credential, so the biscuit comes from the control plane's +// own store (sqlite in WAL mode, readable beside the running process). func enrolledAs(t *testing.T, cpPort int, peerID string, subject string) []byte { t.Helper() record := fetchAdminStatus(t, cpPort, testAdminToken).enrolledNode(peerID) if record == nil { t.Fatalf("control plane :%d has no enrollment for %s", cpPort, peerID) } - if record.Role != api.RoleNode { - t.Fatalf("enrollment role = %q, want %q", record.Role, api.RoleNode) + if record.GetRole() != api.RoleNode { + t.Fatalf("enrollment role = %q, want %q", record.GetRole(), api.RoleNode) } var claims struct { Sub string `json:"sub"` } - if err := json.Unmarshal([]byte(record.ClaimsJSON), &claims); err != nil { - t.Fatalf("enrollment claims %q: %v", record.ClaimsJSON, err) + if err := json.Unmarshal([]byte(record.GetClaimsJson()), &claims); err != nil { + t.Fatalf("enrollment claims %q: %v", record.GetClaimsJson(), err) } if claims.Sub != subject { t.Fatalf("enrolled by %q, want %q", claims.Sub, subject) } - return record.Biscuit + + dir, ok := meshDirs[cpPort] + if !ok { + t.Fatalf("control plane :%d was not started by startMesh", cpPort) + } + cpStore, err := storage.NewSQLStore("sqlite", filepath.Join(dir, "cp-keys.db")) + if err != nil { + t.Fatalf("open control plane store: %v", err) + } + defer func() { _ = cpStore.Close() }() + stored, err := cpStore.GetNode(context.Background(), peerID) + if err != nil { + t.Fatalf("control plane store has no node %s: %v", peerID, err) + } + return stored.Biscuit } func TestSamNodeJoin(t *testing.T) { diff --git a/tests/integration/minimal_helpers_test.go b/tests/integration/minimal_helpers_test.go index e383ba05..2c994d9f 100644 --- a/tests/integration/minimal_helpers_test.go +++ b/tests/integration/minimal_helpers_test.go @@ -41,13 +41,13 @@ import ( "github.com/biscuit-auth/biscuit-go/v2" "github.com/google/sam/api" - "github.com/google/sam/internal/storage" "github.com/libp2p/go-libp2p" dht "github.com/libp2p/go-libp2p-kad-dht" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-msgio" "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -543,11 +543,10 @@ func getFreePort(t *testing.T) int { return 0 } -// adminStatus is the control plane's view of the mesh, in the types it -// serializes on /admin/status. +// adminStatus is the control plane's view of the mesh as /admin/status +// serves it. type adminStatus struct { - EnrolledNodes []storage.EnrolledNode `json:"enrolled_nodes"` - ActiveRouters []storage.RouterLease `json:"active_routers"` + *api.AdminStatusResponse } func fetchAdminStatus(t *testing.T, cpPort int, adminToken string) adminStatus { @@ -566,29 +565,33 @@ func fetchAdminStatus(t *testing.T, cpPort int, adminToken string) adminStatus { if resp.StatusCode != http.StatusOK { t.Fatalf("GET /admin/status: %s", resp.Status) } - var status adminStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read /admin/status: %v", err) + } + status := &api.AdminStatusResponse{} + if err := protojson.Unmarshal(body, status); err != nil { t.Fatalf("decode /admin/status: %v", err) } - return status + return adminStatus{status} } // enrolledNode is the control plane's record of peerID, or nil. -func (s adminStatus) enrolledNode(peerID string) *storage.EnrolledNode { - for i := range s.EnrolledNodes { - if s.EnrolledNodes[i].PeerID == peerID { - return &s.EnrolledNodes[i] +func (s adminStatus) enrolledNode(peerID string) *api.EnrolledNode { + for _, n := range s.GetEnrolledNodes() { + if n.GetPeerId() == peerID { + return n } } return nil } // routerWith is the lease of a router that reports peerID connected, or nil. -func (s adminStatus) routerWith(peerID string) *storage.RouterLease { - for i := range s.ActiveRouters { - for _, p := range s.ActiveRouters[i].ConnectedPeers { +func (s adminStatus) routerWith(peerID string) *api.RouterLease { + for _, lease := range s.GetActiveRouters() { + for _, p := range lease.GetConnectedPeers() { if p == peerID { - return &s.ActiveRouters[i] + return lease } } } @@ -597,7 +600,7 @@ func (s adminStatus) routerWith(peerID string) *storage.RouterLease { // waitForPeerOnRouter returns the lease of the router peerID is connected // to, as the control plane learns it from the router's lease renewals. -func waitForPeerOnRouter(t *testing.T, cpPort int, adminToken string, peerID string, timeout time.Duration) *storage.RouterLease { +func waitForPeerOnRouter(t *testing.T, cpPort int, adminToken string, peerID string, timeout time.Duration) *api.RouterLease { t.Helper() deadline := time.Now().Add(timeout) for { diff --git a/tests/integration/rotation_test.go b/tests/integration/rotation_test.go index 432be162..4651cc66 100644 --- a/tests/integration/rotation_test.go +++ b/tests/integration/rotation_test.go @@ -284,7 +284,7 @@ func waitForLeaseRenewedAfter(t *testing.T, cpPort int, after time.Time) { deadline := time.Now().Add(10 * time.Second) for { for _, lease := range fetchAdminStatus(t, cpPort, "test-admin-token").ActiveRouters { - if lease.LastRenewal.After(after) { + if lease.GetLastRenewalTime().AsTime().After(after) { return } } diff --git a/tests/integration/sdk_enroll_test.go b/tests/integration/sdk_enroll_test.go index 9db0261d..1909d050 100644 --- a/tests/integration/sdk_enroll_test.go +++ b/tests/integration/sdk_enroll_test.go @@ -36,6 +36,7 @@ import ( "github.com/google/sam/internal/storage" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) @@ -304,9 +305,9 @@ func verifySDKReport(t *testing.T, ctx context.Context, store storage.Store, cpP func mintBootstrapToken(t *testing.T, baseURL, adminToken string) string { t.Helper() - body, err := json.Marshal(api.BootstrapTokenRequest{ + body, err := protojson.Marshal(&api.BootstrapTokenCreateRequest{ Role: api.RoleNode, - TTLHours: 1, + TtlHours: 1, MaxUsages: 1, Description: "native sdk conformance", }) @@ -357,18 +358,18 @@ func approvePendingEnrollment(t *testing.T, baseURL, adminToken string, done <-c time.Sleep(50 * time.Millisecond) continue } - var pending []storage.EnrollmentRequest - decodeErr := json.NewDecoder(resp.Body).Decode(&pending) + body, readErr := io.ReadAll(resp.Body) _ = resp.Body.Close() - if decodeErr != nil || resp.StatusCode != http.StatusOK { + pending := &api.EnrollmentRequestListResponse{} + if readErr != nil || resp.StatusCode != http.StatusOK || protojson.Unmarshal(body, pending) != nil { time.Sleep(50 * time.Millisecond) continue } - for _, e := range pending { - if e.Status != api.EnrollmentStatus_ENROLLMENT_STATUS_PENDING { + for _, e := range pending.GetRequests() { + if e.GetStatus() != api.EnrollmentStatus_ENROLLMENT_STATUS_PENDING { continue } - approve, _ := http.NewRequest(http.MethodPost, baseURL+"/admin/enrollments/"+e.ID+"/approve", nil) + approve, _ := http.NewRequest(http.MethodPost, baseURL+"/admin/enrollments/"+e.GetId()+"/approve", nil) approve.Header.Set("Authorization", "Bearer "+adminToken) approveResp, err := client.Do(approve) if err != nil { @@ -379,9 +380,9 @@ func approvePendingEnrollment(t *testing.T, baseURL, adminToken string, done <-c if approveResp.StatusCode != http.StatusOK { t.Fatalf("failed to approve enrollment: %s body %s", approveResp.Status, msg) } - pid, err := peer.Decode(e.PeerID) + pid, err := peer.Decode(e.GetPeerId()) if err != nil { - t.Fatalf("control plane recorded an undecodable peer ID %q: %v", e.PeerID, err) + t.Fatalf("control plane recorded an undecodable peer ID %q: %v", e.GetPeerId(), err) } return pid } diff --git a/tests/ui/console.spec.js b/tests/ui/console.spec.js index 58b30e52..6732f794 100644 --- a/tests/ui/console.spec.js +++ b/tests/ui/console.spec.js @@ -352,20 +352,20 @@ test('reported services render with type, labels and report time', async ({ page const response = await route.fetch(); const status = await response.json(); status.enrolled_nodes = [...(status.enrolled_nodes || []), { - PeerID: PEER, - Role: 'sam:role:node', - OwnerID: 'root-admin', - Labels: { component: 'stvv', region: 'eu-west' }, + peer_id: PEER, + role: 'sam:role:node', + owner_id: 'root-admin', + labels: { component: 'stvv', region: 'eu-west' }, }]; status.node_catalog = { [PEER]: { services: [ - { name: 'compliance-docs', type: 'mcp', description: 'doc lookup' }, + { name: 'compliance-docs', type: 'SERVICE_TYPE_MCP', description: 'doc lookup' }, // Reported names and descriptions are node-controlled input to an // admin page; markup in them must render inert. - { name: 'llama', type: 'inference', description: '' }, + { name: 'llama', type: 'SERVICE_TYPE_INFERENCE', description: '' }, ], - reported_at: '2026-09-15T08:00:00Z', + report_time: '2026-09-15T08:00:00Z', }, }; await route.fulfill({ response, json: status }); @@ -384,7 +384,7 @@ test('reported services render with type, labels and report time', async ({ page await expect(first).toContainText(PEER); // The node's labels ride along as the mnemonic under the peer ID. await expect(first.locator('.cell-subtext')).toHaveText('component=stvv, region=eu-west'); - // reported_at renders as a local time, not the raw RFC 3339 string. + // report_time renders as a local time, not the raw RFC 3339 string. await expect(first.locator('td').nth(4)).not.toHaveText(/2026-09-15T08:00:00Z|-/); await expect(rows.nth(1)).toContainText('inference'); @@ -408,8 +408,8 @@ test('revoked nodes disappear from the Nodes view and the node count', async ({ const response = await route.fetch(); const status = await response.json(); status.enrolled_nodes = [ - { PeerID: LIVE, Role: 'sam:role:node', OwnerID: 'root-admin', Banned: false }, - { PeerID: REVOKED, Role: 'sam:role:node', OwnerID: 'root-admin', Banned: true }, + { peer_id: LIVE, role: 'sam:role:node', owner_id: 'root-admin' }, + { peer_id: REVOKED, role: 'sam:role:node', owner_id: 'root-admin', banned: true }, ]; await route.fulfill({ response, json: status }); });