Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,245 changes: 707 additions & 538 deletions api/ateapi/v1/ateapi.pb.go

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions api/ateapi/v1/ateapi.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2636,6 +2636,37 @@ message ActorAssignment {
Resources resources = 4;
}

// Authentication reports how ateapi authenticated the caller. It is separate
// from Control because it operates on no resource.
service Authentication {
// WhoAmI returns the principal ateapi authenticated the caller as. Any
// authenticated caller may call it.
rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse);
}

message WhoAmIRequest {}

message WhoAmIResponse {
// The principal ID that authorization policies name: the subject of a
// bearer JWT, or the first URI SAN of an mTLS client certificate.
string id = 1;

// How the caller authenticated.
PrincipalKind kind = 2;

// The issuer of the caller's bearer JWT. Empty for mTLS callers.
string issuer = 3;
}

// PrincipalKind is the authentication method that established a principal.
enum PrincipalKind {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

cc Julian Gutierrez Oschmann (@juli4n) Tim Hockin (@thockin) , we were discussing only using strings instead of proto enums for compat reasons. We should decide on that ASAP #1378

PRINCIPAL_KIND_UNSPECIFIED = 0;
// A bearer JWT verified against a configured JWT provider.
PRINCIPAL_KIND_JWT = 1;
// An mTLS client certificate.
PRINCIPAL_KIND_MTLS = 2;
}

// WorkerService is how a Worker tells the control plane what it observes. It is
// separate from Control because the two have different callers and different
// authorization: Control is the client-facing API, while these RPCs are served
Expand Down
112 changes: 112 additions & 0 deletions api/ateapi/v1/ateapi_grpc.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions cmd/ateapi/internal/apiauthn/whoami.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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 apiauthn

import (
"context"

"go.ate.dev/substrate/api/ateapi/v1"
"go.ate.dev/substrate/internal/apierror"
"go.ate.dev/substrate/internal/principal"
)

// AuthenticationService serves the Authentication gRPC service. It reads the
// principal that the interceptor attached, so it must be served behind it.
type AuthenticationService struct {
ateapi.UnimplementedAuthenticationServer
}

func (AuthenticationService) WhoAmI(ctx context.Context, _ *ateapi.WhoAmIRequest) (*ateapi.WhoAmIResponse, error) {
p, ok := principal.FromContext(ctx)
if !ok || p.ID == "" {
return nil, apierror.Unauthenticated("unauthenticated: missing principal in context")
}
kind := ateapi.PrincipalKind_PRINCIPAL_KIND_UNSPECIFIED
switch p.Kind {
case principal.KindJWT:
kind = ateapi.PrincipalKind_PRINCIPAL_KIND_JWT
case principal.KindMTLS:
kind = ateapi.PrincipalKind_PRINCIPAL_KIND_MTLS
}
return &ateapi.WhoAmIResponse{Id: p.ID, Kind: kind, Issuer: p.Issuer}, nil
}
64 changes: 64 additions & 0 deletions cmd/ateapi/internal/apiauthn/whoami_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 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 apiauthn

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
"go.ate.dev/substrate/api/ateapi/v1"
"go.ate.dev/substrate/internal/apierror"
"go.ate.dev/substrate/internal/principal"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/testing/protocmp"
)

func TestWhoAmI(t *testing.T) {
tests := []struct {
name string
p principal.PrincipalInfo
want *ateapi.WhoAmIResponse
}{
{
name: "jwt",
p: principal.PrincipalInfo{ID: "system:serviceaccount:ate-users:alice", Kind: principal.KindJWT, Issuer: "https://kubernetes.default.svc"},
want: &ateapi.WhoAmIResponse{Id: "system:serviceaccount:ate-users:alice", Kind: ateapi.PrincipalKind_PRINCIPAL_KIND_JWT, Issuer: "https://kubernetes.default.svc"},
},
{
name: "mtls",
p: principal.PrincipalInfo{ID: "spiffe://cluster.local/ns/ate-system/sa/atelet", Kind: principal.KindMTLS},
want: &ateapi.WhoAmIResponse{Id: "spiffe://cluster.local/ns/ate-system/sa/atelet", Kind: ateapi.PrincipalKind_PRINCIPAL_KIND_MTLS},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := AuthenticationService{}.WhoAmI(principal.InjectContext(context.Background(), tc.p), &ateapi.WhoAmIRequest{})
if err != nil {
t.Fatalf("WhoAmI: %v", err)
}
if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" {
t.Errorf("WhoAmI (-want +got):\n%s", diff)
}
})
}
}

func TestWhoAmIWithoutPrincipal(t *testing.T) {
_, err := AuthenticationService{}.WhoAmI(context.Background(), &ateapi.WhoAmIRequest{})
if got := apierror.Code(err); got != codes.Unauthenticated {
t.Errorf("WhoAmI without principal: code %v, want %v", got, codes.Unauthenticated)
}
}
3 changes: 3 additions & 0 deletions cmd/ateapi/internal/authz/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ var defaultRPCPermissions = map[string]rpcRule{
return onTag(RelationCanDelete, r.GetTag(), field.NewPath("tag"))
})),

// WhoAmI reveals only the caller's own principal.
ateapi.Authentication_WhoAmI_FullMethodName: inHandler("any authenticated caller"),

// WorkerService is served only to atelet, which each handler
// authenticates by its mTLS SPIFFE ID.
ateapi.WorkerService_AcquireWorkerEpoch_FullMethodName: inHandler("ateletauth: caller is atelet on the Worker's node"),
Expand Down
1 change: 1 addition & 0 deletions cmd/ateapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ func main() {
)
reflection.Register(mux)
ateapi.RegisterControlServer(mux, controlSrv)
ateapi.RegisterAuthenticationServer(mux, apiauthn.AuthenticationService{})
ateapi.RegisterWorkerServiceServer(mux, workerservice.New(persistence, controlSrv, ateletSPIFFEID, actorIDCAPool, *actorJWTIssuer, actorIDJWTAuthorityPool))

// Every RPC needs the store, so a replica that cannot reach it leaves the
Expand Down
21 changes: 21 additions & 0 deletions cmd/kubectl-ate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ By default, `kubectl-ate` will automatically read your `~/.kube/config`, discove

If you prefer to route traffic directly (e.g., through a LoadBalancer or when running natively inside a cluster pod), simply provide the `--endpoint` flag to bypass the tunnel.

## Authentication
`kubectl-ate` authenticates to ate-api with the first of these it is given:

1. `--token-file`: a bearer token file, re-read on every RPC, or `-` for stdin.
2. `--credential-plugin`, else `$ATE_CREDENTIAL_PLUGIN`: a [kubectl credential plugin](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins). A path to an existing file runs with no arguments; any other value is split into a command and its arguments, honoring shell quoting. The plugin receives `--kubeconfig` and `--context` as `$ATE_KUBECONFIG` and `$ATE_KUBE_CONTEXT`. `kubectl-ate` caches its credential until the `expirationTimestamp`, and runs it again when ate-api rejects the credential.
3. A token minted for the shared `ate-client` ServiceAccount.

Passing both `--token-file` and `--credential-plugin` is an error.

```bash
# Authenticate as your own ServiceAccount (see docs/authentication.md)
export ATE_CREDENTIAL_PLUGIN="kubectl-ate credential --service-account=ate-users/alice"

# Show the principal ate-api authenticated you as
kubectl ate whoami

# Print a raw bearer token, e.g. for grpcurl
kubectl ate credential -o token
```

## Tracing
The CLI supports on-demand tracing using the `--trace` flag. When enabled, the CLI will generate a trace ID and signal to the server that it wants the request to be traced.

Expand Down Expand Up @@ -67,6 +87,7 @@ These flags can be appended to any command:
| `--context` | | Name of the kubeconfig context to use | current context |
| `--endpoint` | | Manual gRPC endpoint override (e.g., `localhost:8080`) | |
| `--token-file` | | Path to a bearer token for ate-api authentication, or `-` for stdin | Kubernetes ServiceAccount token |
| `--credential-plugin` | | kubectl credential plugin that provides the ate-api credential: an executable's path, or a command line | `$ATE_CREDENTIAL_PLUGIN` |
| `--output` | `-o` | Output format (`table`, `json`, `yaml`) | `table` |
| `--trace` | | Enable on-demand tracing for the request | `false` |
| `--verbose` | `-v` | Increase output verbosity; may be repeated or stacked (e.g., `-v`, `-vv`, `-vvvvv`) | `0` |
Expand Down
Loading
Loading