diff --git a/cmd/objectstore-plugin/README.md b/cmd/objectstore-plugin/README.md index e8a284f0cf..a881cde70e 100644 --- a/cmd/objectstore-plugin/README.md +++ b/cmd/objectstore-plugin/README.md @@ -68,10 +68,11 @@ This binary's, GCS and S3: S3 has no issuer: its node plugin ignores the JWT and transfers with the pod's own credentials, and the exchange answers `UNIMPLEMENTED` there. A -provider other than these two brings its own binary in this repository, with -its own exchange handler and proto shaped for its credential, its own issuer -and storage client, and imports the four reusable packages. A binary outside -the repository cannot, since they are under `internal/`. +provider other than these two brings its own binary, with its own exchange +handler and proto shaped for its credential, its own issuer and storage +client. In this repository it imports the four reusable packages. Outside it, +it imports `pkg/objectstore/actorauthz`, which runs the same checks and returns +the grants with each prefix as a URI ending in `/`. ## Upload memory diff --git a/cmd/objectstore-plugin/exchange.go b/cmd/objectstore-plugin/exchange.go index 4d390954c4..800d744103 100644 --- a/cmd/objectstore-plugin/exchange.go +++ b/cmd/objectstore-plugin/exchange.go @@ -33,7 +33,6 @@ import ( "go.ate.dev/substrate/internal/objectstoreplugin/actorlookup" "go.ate.dev/substrate/internal/objectstoreplugin/exchange" "go.ate.dev/substrate/internal/objectstoreplugin/gcsissuer" - "go.ate.dev/substrate/internal/oidcjwt" exchangepb "go.ate.dev/substrate/internal/proto/objectstoreexchangepb" "go.ate.dev/substrate/internal/serverboot" "go.ate.dev/substrate/pkg/objectstorage" @@ -69,13 +68,10 @@ func newExchangeService(ctx context.Context, cfg exchangeConfig) (exchangepb.Cre if cfg.transferAccount == "" { return nil, nil, errors.New("--transfer-service-account is required to serve the exchange on GCS") } - // Read the key set once now, so a wrong path fails the start rather - // than the first exchange. - keys := oidcjwt.FileKeySource(cfg.jwksFile) - if _, err := keys.Keys(ctx); err != nil { - return nil, nil, fmt.Errorf("while reading the actor JWT key set: %w", err) + verifier, err := actorauthz.NewVerifier(ctx, cfg.issuer, cfg.jwksFile) + if err != nil { + return nil, nil, err } - verifier := oidcjwt.NewVerifier(cfg.issuer, []string{actorauthz.Audience}, keys, oidcjwt.WithSubstrateClaims()) lookup, err := actorlookup.New(cfg.ateapi) if err != nil { return nil, nil, err diff --git a/hack/verify/package-deps.sh b/hack/verify/package-deps.sh index 29d6bb0a5c..907f29219a 100755 --- a/hack/verify/package-deps.sh +++ b/hack/verify/package-deps.sh @@ -30,8 +30,9 @@ ateapi_banned=( github.com/aws/aws-sdk-go-v2/service/s3 ) -# Every provider's exchange imports actorauthz, so it may not depend on one -# provider's wire format, credential or cloud SDK. +# Every provider's exchange imports actorauthz, in-tree or through +# pkg/objectstore/actorauthz, so neither may depend on one provider's wire +# format, credential or cloud SDK. actorauthz_banned=( go.ate.dev/substrate/internal/proto/objectstoreexchangepb go.ate.dev/substrate/internal/objectstoreplugin/credential @@ -76,5 +77,6 @@ check() { check ./cmd/ateapi "${ateapi_banned[@]}" check ./internal/objectstoreplugin/actorauthz "${actorauthz_banned[@]}" +check ./pkg/objectstore/actorauthz "${actorauthz_banned[@]}" exit "${status}" diff --git a/hack/verify/pkg-external-import.sh b/hack/verify/pkg-external-import.sh new file mode 100755 index 0000000000..7ed30afc57 --- /dev/null +++ b/hack/verify/pkg-external-import.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +# 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. + +# Builds every pkg/ package that imports internal/ from a module outside +# go.ate.dev/substrate, as an external consumer would, and checks that the +# same module cannot import internal/ directly. Go's internal rule allows the +# first and refuses the second. + +set -o errexit -o nounset -o pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "${ROOT}" + +# Each output is assigned first so errexit stops the script if go list or +# awk fails. +listed="$(go list -mod=vendor -f '{{.ImportPath}} {{join .Imports " "}}' ./pkg/...)" +importers="$(awk '/ go\.ate\.dev\/substrate\/internal\// {print $1}' <<<"${listed}")" +if [[ -z "${importers}" ]]; then + echo "No pkg/ package imports internal/." + exit 0 +fi +pkgs=() +while IFS= read -r pkg; do + pkgs+=("${pkg}") +done <<<"${importers}" + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT +go_version="$(awk '$1 == "go" {print $2; exit}' go.mod)" +cat >"${tmp}/go.mod" < ${ROOT} +EOF +cp go.sum "${tmp}/go.sum" +mkdir "${tmp}/public" "${tmp}/direct" +{ + printf 'package public\n\nimport (\n' + printf '\t_ "%s"\n' "${pkgs[@]}" + printf ')\n' +} >"${tmp}/public/public.go" +cat >"${tmp}/direct/direct.go" <&2 + echo "A module outside go.ate.dev/substrate cannot build ${pkgs[*]}." >&2 + exit 1 +fi +if out="$(go build ./direct 2>&1)"; then + echo "A module outside go.ate.dev/substrate imported internal/." >&2 + exit 1 +fi +if ! grep -Fq "use of internal package go.ate.dev/substrate/internal/oidcjwt not allowed" <<<"${out}"; then + echo "${out}" >&2 + echo "Building the direct internal/ import failed for an unexpected reason." >&2 + exit 1 +fi +echo "Built from an external module: ${pkgs[*]}" diff --git a/internal/objectstoreplugin/actorauthz/actorauthz.go b/internal/objectstoreplugin/actorauthz/actorauthz.go index bf00f8bc45..08240619ff 100644 --- a/internal/objectstoreplugin/actorauthz/actorauthz.go +++ b/internal/objectstoreplugin/actorauthz/actorauthz.go @@ -22,6 +22,7 @@ package actorauthz import ( "context" "errors" + "fmt" "slices" "time" @@ -53,6 +54,18 @@ type Verifier interface { Verify(ctx context.Context, jwt string, now time.Time) (*oidcjwt.Claims, error) } +// NewVerifier returns the verifier every exchange checks JWTs with: tokens +// from issuer for Audience, with Substrate's claims, verified with the keys +// in the JWK set file jwksFile. It reads the file once, so a bad path fails +// here rather than on the first exchange. +func NewVerifier(ctx context.Context, issuer, jwksFile string) (*oidcjwt.Verifier, error) { + keys := oidcjwt.FileKeySource(jwksFile) + if _, err := keys.Keys(ctx); err != nil { + return nil, fmt.Errorf("while reading the actor JWT key set: %w", err) + } + return oidcjwt.NewVerifier(issuer, []string{Audience}, keys, oidcjwt.WithSubstrateClaims()), nil +} + // Decision is what Authorize allowed: the UID of the actor a credential may // be issued for, and the storage grants the credential must be limited to. type Decision struct { @@ -67,7 +80,8 @@ type Authorizer struct { now func() time.Time } -// New returns an Authorizer. The verifier must accept only Audience. +// New returns an Authorizer. The verifier must accept only Audience, as one +// from NewVerifier does. func New(v Verifier, l Lookup) *Authorizer { return &Authorizer{verifier: v, lookup: l, now: time.Now} } diff --git a/internal/objectstoreplugin/actorauthz/actorauthz_test.go b/internal/objectstoreplugin/actorauthz/actorauthz_test.go index 750adaa909..8c2f3c0f55 100644 --- a/internal/objectstoreplugin/actorauthz/actorauthz_test.go +++ b/internal/objectstoreplugin/actorauthz/actorauthz_test.go @@ -25,6 +25,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "time" @@ -72,7 +73,10 @@ func newSigner(t *testing.T) (*signer, *oidcjwt.Verifier) { if err := os.WriteFile(path, jwks, 0o600); err != nil { t.Fatal(err) } - v := oidcjwt.NewVerifier(issuer, []string{audience}, oidcjwt.FileKeySource(path), oidcjwt.WithSubstrateClaims()) + v, err := NewVerifier(t.Context(), issuer, path) + if err != nil { + t.Fatal(err) + } return &signer{pool: pool, now: time.Now(), node: "node-1"}, v } @@ -325,3 +329,19 @@ func TestAuthorize_Refusals(t *testing.T) { }) } } + +func TestNewVerifier_BadKeySet(t *testing.T) { + dir := t.TempDir() + notJSON := filepath.Join(dir, "not-json") + if err := os.WriteFile(notJSON, []byte("not a key set"), 0o600); err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Join(dir, "absent.json"), notJSON} { + t.Run(filepath.Base(path), func(t *testing.T) { + _, err := NewVerifier(t.Context(), issuer, path) + if err == nil || !strings.HasPrefix(err.Error(), "while reading the actor JWT key set: ") { + t.Errorf("error = %v, want one about reading the key set", err) + } + }) + } +} diff --git a/pkg/objectstore/actorauthz/actorauthz.go b/pkg/objectstore/actorauthz/actorauthz.go new file mode 100644 index 0000000000..95e1b3fdf9 --- /dev/null +++ b/pkg/objectstore/actorauthz/actorauthz.go @@ -0,0 +1,203 @@ +// 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 actorauthz runs the checks Substrate's GCS credential exchange runs, +// for an object-store provider that serves an exchange of its own. Authorize +// verifies the worker-for-actor JWT atelet sends with a transfer, looks the +// actor up in ate-api-server, refuses a token for an actor that has left the +// token's node or has no transfer due, and returns the storage prefixes the +// credential must be limited to. The provider issues the credential and owns +// its wire format. +// +// The exchange must run in the ate-api-server pod, as the objectstore-plugin +// control sidecar does. ate-api-server lets that pod's identity read actors +// through the api_server_sidecar role, see docs/guides/authorization.md. +// APIServerConfig dials ate-api-server over the loopback with the pod identity +// certificate. +// +// The JWTs' public keys come from the jwks key of the actor-id-jwt-pool +// Secret, mounted into the pod as a file that Config.JWKSFile names. Mount it +// without subPath, so a rotated key set is picked up. +// +// This package imports packages under internal/, so that a provider runs the +// same code as the in-tree exchange and only this package's API is public. Go +// allows it: the internal rule checks the importer's path, and every package +// under go.ate.dev/substrate may import go.ate.dev/substrate/internal/..., +// while code outside the module may import this package. +package actorauthz + +import ( + "context" + "errors" + "fmt" + + "go.ate.dev/substrate/internal/actoridjwt" + internalauthz "go.ate.dev/substrate/internal/objectstoreplugin/actorauthz" + "go.ate.dev/substrate/internal/objectstoreplugin/actorlookup" +) + +// Audience is the audience of the actor_jwt field in the objectstore v1 API +// (go.ate.dev/substrate/api/objectstore/v1). atelet requests it on the +// worker-for-actor JWT it sends with a transfer. Authorize refuses a token +// minted for any other audience. +const Audience = "objectstore-plugin.ate.dev" + +// Config says how an Authorizer verifies JWTs and reaches ate-api-server. +type Config struct { + // Issuer is the issuer URL of the actor JWTs, the value ate-api-server + // runs with. It must be https:// followed by a host and optional port, + // with no path, query or fragment. + Issuer string + // JWKSFile is the JWK set file holding the public keys the JWTs are + // verified with. It is re-read when the verifier refreshes its keys, so + // it must be replaced atomically, as a projected volume is. + JWKSFile string + // APIServer says how to reach ate-api-server. + APIServer APIServerConfig +} + +// APIServerConfig says how to reach ate-api-server. Every file is re-read on +// each handshake, so certificate rotation needs no restart. +type APIServerConfig struct { + // Address is the gRPC target, 127.0.0.1:443 in the pod. + Address string + // CAFile verifies the serving certificate. + CAFile string + // ServerName is the DNS name expected on the serving certificate, since + // Address is not a name the certificate carries. It is required: with no + // name, any certificate the CA signed would be accepted. + ServerName string + // ClientCredBundle is the PEM bundle with the pod identity certificate + // and key. + ClientCredBundle string +} + +// Access is what a grant permits under its prefix. +type Access int + +const ( + // ReadOnly covers the tag or golden snapshot the actor was created from. + ReadOnly Access = iota + 1 + // ReadWrite covers the actor's own snapshots. + ReadWrite +) + +func (a Access) String() string { + switch a { + case ReadOnly: + return "ro" + case ReadWrite: + return "rw" + } + return fmt.Sprintf("Access(%d)", int(a)) +} + +// Grant is one storage prefix and the access allowed under it. +type Grant struct { + // Prefix is the storage prefix URI, such as + // gs://bucket/root/atespaces/team-a/actors//. It ends in "/", so a + // string prefix match cannot reach a sibling whose name starts the same + // way, such as another actor whose UID begins with this one's. It is + // percent-encoded, as snapshot URIs are. Parse it with url.Parse before + // comparing it with object names: the host is the bucket, and the path + // without its leading "/" is the object name prefix. + Prefix string + Access Access +} + +// Decision is what Authorize allowed: the UID of the actor a credential may +// be issued for, and the grants the credential must be limited to. Grants are +// in the order the GCS exchange gets them, ReadWrite before ReadOnly. +type Decision struct { + ActorUID string + Grants []Grant +} + +// Authorizer runs the checks. It is safe for concurrent use. +type Authorizer struct { + checks *internalauthz.Authorizer + lookup *actorlookup.Lookup +} + +// New returns an Authorizer that accepts tokens from cfg.Issuer for Audience. +// It reads the key set once, so a bad JWKSFile fails here rather than on the +// first Authorize. The connection to ate-api-server is lazy, so a server that +// is not up yet fails the first Authorize instead. Close releases it. +func New(ctx context.Context, cfg Config) (*Authorizer, error) { + if cfg.Issuer == "" { + return nil, errors.New("actorauthz: Issuer is required") + } + if err := actoridjwt.ValidateIssuer(cfg.Issuer); err != nil { + return nil, fmt.Errorf("actorauthz: %w", err) + } + if cfg.JWKSFile == "" { + return nil, errors.New("actorauthz: JWKSFile is required") + } + if cfg.APIServer.Address == "" { + return nil, errors.New("actorauthz: APIServer.Address is required") + } + if cfg.APIServer.CAFile == "" { + return nil, errors.New("actorauthz: APIServer.CAFile is required") + } + if cfg.APIServer.ServerName == "" { + return nil, errors.New("actorauthz: APIServer.ServerName is required") + } + if cfg.APIServer.ClientCredBundle == "" { + return nil, errors.New("actorauthz: APIServer.ClientCredBundle is required") + } + verifier, err := internalauthz.NewVerifier(ctx, cfg.Issuer, cfg.JWKSFile) + if err != nil { + return nil, fmt.Errorf("actorauthz: %w", err) + } + lookup, err := actorlookup.New(actorlookup.Config(cfg.APIServer)) + if err != nil { + return nil, fmt.Errorf("actorauthz: %w", err) + } + return &Authorizer{checks: internalauthz.New(verifier, lookup), lookup: lookup}, nil +} + +// Close closes the connection to ate-api-server. +func (a *Authorizer) Close() error { return a.lookup.Close() } + +// Authorize checks jwt and returns what a credential may be issued for. Only +// a worker-for-actor token is accepted, never an actor's own. Every error is +// a gRPC status error the exchange can return as is. Callers may rely on the +// codes, not the messages: +// +// - InvalidArgument: jwt is empty. +// - Unauthenticated: jwt fails verification, lacks the actor's atespace, +// name, UID or node, or names an actor ate-api-server rejects. +// - PermissionDenied: jwt is an actor's own token, the actor is in a state +// with no transfer due or has left the token's node, or the actor has no +// snapshot location. +// - NotFound: the actor is gone or was recreated with another UID. +// - FailedPrecondition: ate-api-server refuses to let this pod read actors. +// - Unavailable: the actor lookup failed otherwise. +// - Internal: the actor's storage grants cannot be derived from its +// snapshot URIs. +// - Canceled, DeadlineExceeded: the actor lookup was canceled or timed out. +func (a *Authorizer) Authorize(ctx context.Context, jwt string) (*Decision, error) { + d, err := a.checks.Authorize(ctx, jwt) + if err != nil { + return nil, err + } + grants := make([]Grant, 0, len(d.Grants)) + for _, g := range d.Grants { + // objectstore.BucketPrefix appends "/" the same way for the GCS + // boundary. It is not called here because its package links cloud + // SDKs. + grants = append(grants, Grant{Prefix: g.Prefix.String() + "/", Access: Access(g.Access)}) + } + return &Decision{ActorUID: d.ActorUID, Grants: grants}, nil +} diff --git a/pkg/objectstore/actorauthz/actorauthz_test.go b/pkg/objectstore/actorauthz/actorauthz_test.go new file mode 100644 index 0000000000..e3374dac11 --- /dev/null +++ b/pkg/objectstore/actorauthz/actorauthz_test.go @@ -0,0 +1,499 @@ +// 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 actorauthz_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "math/big" + "net" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" + + "go.ate.dev/substrate/api/ateapi/v1" + "go.ate.dev/substrate/internal/actoridjwt" + "go.ate.dev/substrate/internal/localca" + "go.ate.dev/substrate/internal/localjwtauthority" + "go.ate.dev/substrate/internal/objectstore" + internalauthz "go.ate.dev/substrate/internal/objectstoreplugin/actorauthz" + "go.ate.dev/substrate/internal/objectstoreplugin/actorscope" + "go.ate.dev/substrate/internal/resources" + "go.ate.dev/substrate/internal/testcert" + "go.ate.dev/substrate/pkg/objectstore/actorauthz" +) + +const ( + issuer = "https://idp.ate-system.svc" + serverName = "api.ate-system.svc" + atespace = "team-a" + name = "bot" + uid = "9f1e0b2a-9e4f-4c3d-8a6b-5f2e1d0c9b8a" + location = "gs://snaps/root" + tagURI = location + "/atespaces/ate-golden/tags/7a3c0b2a-9e4f-4c3d-8a6b-5f2e1d0c9b8a" +) + +// env is what every Authorizer in a test shares: the JWT keys and the PKI the +// fake ate-api-server and its client use. +type env struct { + pool *localjwtauthority.ConcretePool + jwksFile string + ca *localca.CA + caFile string + bundle string +} + +func newEnv(t *testing.T) *env { + t.Helper() + dir := t.TempDir() + auth, err := localjwtauthority.GenerateAuthority("ES256", "k1") + if err != nil { + t.Fatal(err) + } + pool := &localjwtauthority.ConcretePool{Authorities: []*localjwtauthority.Authority{auth}, ActiveForSigning: "k1"} + jwks, err := pool.JWKS() + if err != nil { + t.Fatal(err) + } + e := &env{pool: pool, jwksFile: writeFile(t, dir, "jwks.json", jwks)} + e.ca, err = localca.GenerateCA("test", localca.KeyTypeECDSAP256, time.Hour) + if err != nil { + t.Fatal(err) + } + e.caFile = writeFile(t, dir, "ca.pem", pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.ca.RootCertificate.Raw})) + e.bundle = writeFile(t, dir, "client.pem", clientBundle(t, e.ca)) + return e +} + +func writeFile(t *testing.T, dir, name string, data []byte) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// clientBundle issues the pod identity certificate the Authorizer presents. +func clientBundle(t *testing.T, ca *localca.CA) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + spiffeID, err := url.Parse("spiffe://ate.dev/ns/ate-system/sa/ate-api-server") + if err != nil { + t.Fatal(err) + } + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(2), + URIs: []*url.URL{spiffeID}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + }, ca.RootCertificate, key.Public(), ca.SigningKey) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return append( + pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})..., + ) +} + +// fakeControl answers GetActor for the one actor it holds, or with err. +type fakeControl struct { + ateapi.UnimplementedControlServer + actor *ateapi.Actor + err error +} + +func (f *fakeControl) GetActor(_ context.Context, req *ateapi.GetActorRequest) (*ateapi.Actor, error) { + if f.err != nil { + return nil, f.err + } + if req.GetActor().GetAtespace() != f.actor.GetMetadata().GetAtespace() || req.GetActor().GetName() != f.actor.GetMetadata().GetName() { + return nil, status.Errorf(codes.NotFound, "no actor %s/%s", req.GetActor().GetAtespace(), req.GetActor().GetName()) + } + return f.actor, nil +} + +// serve runs fake as ate-api-server over mTLS on the loopback, and returns +// the Config of an Authorizer that reaches it. +func (e *env) serve(t *testing.T, fake *fakeControl) actorauthz.Config { + t.Helper() + material := testcert.ServerTLSWithCA(t, e.ca, net.ParseIP("127.0.0.1"), serverName) + cert, err := tls.X509KeyPair(material.Certificate, material.PrivateKey) + if err != nil { + t.Fatal(err) + } + clientCAs := x509.NewCertPool() + clientCAs.AddCert(e.ca.RootCertificate) + srv := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + }))) + ateapi.RegisterControlServer(srv, fake) + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + return actorauthz.Config{ + Issuer: issuer, + JWKSFile: e.jwksFile, + APIServer: actorauthz.APIServerConfig{ + Address: lis.Addr().String(), + CAFile: e.caFile, + ServerName: serverName, + ClientCredBundle: e.bundle, + }, + } +} + +// token is what jwt mints. The zero value is a worker-for-actor token for +// the actor uid on node-1, for the objectstore plugin audience. +type token struct { + kind actoridjwt.Kind + audience string + uid string +} + +func (e *env) jwt(t *testing.T, tok token) string { + t.Helper() + if tok.kind == actoridjwt.KindUnspecified { + tok.kind = actoridjwt.KindWorkerForActor + } + if tok.audience == "" { + tok.audience = actorauthz.Audience + } + if tok.uid == "" { + tok.uid = uid + } + substrate := actoridjwt.SubstrateClaims{Atespace: atespace, ActorName: name, ActorUID: tok.uid} + if tok.kind == actoridjwt.KindWorkerForActor { + substrate.Node = "node-1" + } + now := time.Now() + jwt, err := e.pool.SignJWT(&actoridjwt.Claims{ + Kind: tok.kind, + Issuer: issuer, + Subject: tok.kind.SubjectPrefix() + "/" + atespace + "/" + name, + Audiences: []string{tok.audience}, + Expiration: now.Add(time.Hour), + NotBefore: now.Add(-time.Minute), + IssuedAt: now, + Substrate: substrate, + }) + if err != nil { + t.Fatal(err) + } + return jwt +} + +// actor returns a RUNNING actor on node-1 with the given UID and snapshots. +func actor(actorUID string, snapshots ...*ateapi.Snapshot) *ateapi.Actor { + return &ateapi.Actor{ + Metadata: &ateapi.ResourceMetadata{Atespace: atespace, Name: name, Uid: actorUID}, + Status: &ateapi.ActorStatus{State: ateapi.ActorState_ACTOR_STATE_RUNNING, AssignedNode: "node-1", Snapshots: snapshots}, + } +} + +func durable(gen int32, owner ateapi.SnapshotOwner, st ateapi.SnapshotStorageState, uri string) *ateapi.Snapshot { + return &ateapi.Snapshot{Generation: gen, Owner: owner, DurableSnapshot: &ateapi.SnapshotStorage{State: st, Object: &ateapi.ObjectSnapshot{SnapshotUri: uri}}} +} + +var ( + ownerActor = ateapi.SnapshotOwner_SNAPSHOT_OWNER_ACTOR + ownerTag = ateapi.SnapshotOwner_SNAPSHOT_OWNER_TAG + completed = ateapi.SnapshotStorageState_SNAPSHOT_STORAGE_STATE_COMPLETED + pending = ateapi.SnapshotStorageState_SNAPSHOT_STORAGE_STATE_PENDING +) + +func actorPrefix(actorUID string) string { + return actorPrefixAt(location, actorUID) +} + +func actorPrefixAt(loc, actorUID string) string { + return loc + "/atespaces/" + atespace + "/actors/" + actorUID +} + +func TestAuthorize(t *testing.T) { + e := newEnv(t) + for _, tc := range []struct { + name string + actor *ateapi.Actor + jwt token + want *actorauthz.Decision + // inside and outside are object URIs a string prefix match on the + // grants must and must not accept. + inside, outside []string + }{ + { + name: "actor created from a tag", + actor: actor(uid, durable(1, ownerTag, completed, tagURI)), + want: &actorauthz.Decision{ + ActorUID: uid, + Grants: []actorauthz.Grant{ + {Prefix: actorPrefix(uid) + "/", Access: actorauthz.ReadWrite}, + {Prefix: tagURI + "/", Access: actorauthz.ReadOnly}, + }, + }, + inside: []string{actorPrefix(uid) + "/snapshots/s1/manifest", tagURI + "/manifest"}, + }, + { + name: "UID that begins another actor's UID", + actor: actor("abc", durable(2, ownerActor, pending, actorPrefix("abc")+"/snapshots/s1")), + jwt: token{uid: "abc"}, + want: &actorauthz.Decision{ + ActorUID: "abc", + Grants: []actorauthz.Grant{{Prefix: actorPrefix("abc") + "/", Access: actorauthz.ReadWrite}}, + }, + inside: []string{actorPrefix("abc") + "/snapshots/s1/manifest"}, + outside: []string{actorPrefix("abcd") + "/snapshots/s1/manifest", actorPrefix("abcd")}, + }, + { + name: "location with an escaped space", + actor: actor(uid, durable(2, ownerActor, pending, actorPrefixAt("gs://snaps/my%20root", uid)+"/snapshots/s1")), + want: &actorauthz.Decision{ + ActorUID: uid, + Grants: []actorauthz.Grant{{Prefix: actorPrefixAt("gs://snaps/my%20root", uid) + "/", Access: actorauthz.ReadWrite}}, + }, + inside: []string{actorPrefixAt("gs://snaps/my%20root", uid) + "/snapshots/s1/manifest"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + a, err := actorauthz.New(t.Context(), e.serve(t, &fakeControl{actor: tc.actor})) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { + if err := a.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + got, err := a.Authorize(t.Context(), e.jwt(t, tc.jwt)) + if err != nil { + t.Fatalf("Authorize: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("decision = %+v, want %+v", got, tc.want) + } + for _, g := range got.Grants { + assertGCSBoundaryPrefix(t, g.Prefix) + } + for _, uri := range tc.inside { + if !coveredByString(got.Grants, uri) { + t.Errorf("no grant prefix matches %s", uri) + } + } + for _, uri := range tc.outside { + if coveredByString(got.Grants, uri) { + t.Errorf("a grant prefix matches %s", uri) + } + } + }) + } +} + +func coveredByString(grants []actorauthz.Grant, uri string) bool { + for _, g := range grants { + if strings.HasPrefix(uri, g.Prefix) { + return true + } + } + return false +} + +// assertGCSBoundaryPrefix checks prefix parses to the bucket and object path +// the GCS boundary builds its condition from, through objectstore.BucketPrefix. +func assertGCSBoundaryPrefix(t *testing.T, prefix string) { + t.Helper() + sp, err := resources.ParseStoragePrefix(strings.TrimSuffix(prefix, "/")) + if err != nil { + t.Fatal(err) + } + bucket, path, err := objectstore.BucketPrefix(sp) + if err != nil { + t.Fatal(err) + } + u, err := url.Parse(prefix) + if err != nil { + t.Fatal(err) + } + if u.Host != bucket || strings.TrimPrefix(u.Path, "/") != path { + t.Errorf("grant prefix %s parses to bucket %q path %q, want %q %q from objectstore.BucketPrefix", prefix, u.Host, u.Path, bucket, path) + } +} + +func TestAuthorize_Refusals(t *testing.T) { + e := newEnv(t) + // notValid is the message for a token the verifier refuses, which wraps + // the verifier's own error. + verifier, err := internalauthz.NewVerifier(t.Context(), issuer, e.jwksFile) + if err != nil { + t.Fatal(err) + } + notValid := func(jwt string) string { + _, err := verifier.Verify(t.Context(), jwt, time.Now()) + if err == nil { + t.Fatal("verifier accepted a token the row expects it to refuse") + } + return "actor_jwt is not valid: " + err.Error() + } + wrongAudience := e.jwt(t, token{audience: "other"}) + badURI := actor(uid, durable(1, ownerTag, completed, "gs://snaps/not-a-snapshot")) + _, errBadURI := actorscope.FromActor(badURI) + if errBadURI == nil { + t.Fatal("actorscope accepted the bad snapshot URI") + } + tagged := actor(uid, durable(1, ownerTag, completed, tagURI)) + elsewhere := actor(uid, durable(1, ownerTag, completed, tagURI)) + elsewhere.Status.AssignedNode = "node-2" + crashed := actor(uid, durable(1, ownerTag, completed, tagURI)) + crashed.Status.State = ateapi.ActorState_ACTOR_STATE_CRASHED + for _, tc := range []struct { + name string + jwt string + fake *fakeControl + canceled bool + want codes.Code + wantMsg string + }{ + {name: "empty jwt", want: codes.InvalidArgument, wantMsg: "actor_jwt is required"}, + {name: "wrong audience", jwt: wrongAudience, want: codes.Unauthenticated, wantMsg: notValid(wrongAudience)}, + {name: "actor's own token", jwt: e.jwt(t, token{kind: actoridjwt.KindActor}), want: codes.PermissionDenied, wantMsg: "only a worker-for-actor token may be exchanged"}, + {name: "recreated actor", jwt: e.jwt(t, token{uid: "old-uid"}), want: codes.NotFound, wantMsg: "actor not found"}, + {name: "actor gone", fake: &fakeControl{err: status.Error(codes.NotFound, "no such actor")}, want: codes.NotFound, wantMsg: "actor not found"}, + {name: "ate-api-server rejects the actor ref", fake: &fakeControl{err: status.Error(codes.InvalidArgument, "bad name")}, want: codes.Unauthenticated, wantMsg: "actor_jwt names an invalid actor: rpc error: code = InvalidArgument desc = bad name"}, + {name: "exchange lacks the actor read role", fake: &fakeControl{err: status.Error(codes.PermissionDenied, "not allowed")}, want: codes.FailedPrecondition, wantMsg: "the exchange may not read actors: rpc error: code = PermissionDenied desc = not allowed"}, + {name: "caller's deadline passed during the lookup", fake: &fakeControl{err: status.Error(codes.DeadlineExceeded, "deadline")}, want: codes.DeadlineExceeded, wantMsg: "deadline"}, + {name: "caller canceled before the lookup", canceled: true, want: codes.Canceled, wantMsg: "context canceled"}, + {name: "ate-api-server fails", fake: &fakeControl{err: status.Error(codes.Internal, "boom")}, want: codes.Unavailable, wantMsg: "while looking up the actor: rpc error: code = Internal desc = boom"}, + {name: "crashed on the token's node", fake: &fakeControl{actor: crashed}, want: codes.PermissionDenied, wantMsg: "actor is ACTOR_STATE_CRASHED, no transfer is due"}, + {name: "resumed on another node", fake: &fakeControl{actor: elsewhere}, want: codes.PermissionDenied, wantMsg: "actor is no longer on the token's node"}, + {name: "snapshot URI actorscope cannot parse", fake: &fakeControl{actor: badURI}, want: codes.Internal, wantMsg: "while deriving the actor's storage grants: " + errBadURI.Error()}, + {name: "no snapshot location", fake: &fakeControl{actor: actor(uid)}, want: codes.PermissionDenied, wantMsg: "actor has no snapshot location to grant"}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := tc.fake + if fake == nil { + fake = &fakeControl{actor: tagged} + } + jwt := tc.jwt + if jwt == "" && tc.want != codes.InvalidArgument { + jwt = e.jwt(t, token{}) + } + a, err := actorauthz.New(t.Context(), e.serve(t, fake)) + if err != nil { + t.Fatalf("New: %v", err) + } + defer a.Close() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if tc.canceled { + cancel() + } + got, err := a.Authorize(ctx, jwt) + if got != nil { + t.Errorf("decision = %+v, want nil", got) + } + if st := status.Convert(err); st.Code() != tc.want || st.Message() != tc.wantMsg { + t.Errorf("error = %v %q, want %v %q", st.Code(), st.Message(), tc.want, tc.wantMsg) + } + }) + } +} + +func TestNew_Errors(t *testing.T) { + e := newEnv(t) + good := e.serve(t, &fakeControl{}) + dir := t.TempDir() + notJSON := writeFile(t, dir, "not-json", []byte("not a key set")) + for _, tc := range []struct { + name string + edit func(*actorauthz.Config) + wantErr string + }{ + {"no issuer", func(c *actorauthz.Config) { c.Issuer = "" }, "Issuer is required"}, + {"http issuer", func(c *actorauthz.Config) { c.Issuer = "http://idp.ate-system.svc" }, "must be https://"}, + {"no JWKS file", func(c *actorauthz.Config) { c.JWKSFile = "" }, "JWKSFile is required"}, + {"missing JWKS file", func(c *actorauthz.Config) { c.JWKSFile = filepath.Join(dir, "absent.json") }, "while reading the actor JWT key set"}, + {"invalid JWKS file", func(c *actorauthz.Config) { c.JWKSFile = notJSON }, "while reading the actor JWT key set"}, + {"no ate-api-server address", func(c *actorauthz.Config) { c.APIServer.Address = "" }, "APIServer.Address is required"}, + {"no CA file", func(c *actorauthz.Config) { c.APIServer.CAFile = "" }, "APIServer.CAFile is required"}, + {"no ate-api-server name", func(c *actorauthz.Config) { c.APIServer.ServerName = "" }, "APIServer.ServerName is required"}, + {"no client credential bundle", func(c *actorauthz.Config) { c.APIServer.ClientCredBundle = "" }, "APIServer.ClientCredBundle is required"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := good + tc.edit(&cfg) + a, err := actorauthz.New(t.Context(), cfg) + if err == nil { + a.Close() + t.Fatal("New succeeded") + } + if !strings.HasPrefix(err.Error(), "actorauthz: ") || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error = %v, want it to start with %q and contain %q", err, "actorauthz: ", tc.wantErr) + } + }) + } +} + +// TestAudience pins Audience to the audience the in-tree verifier requires. +func TestAudience(t *testing.T) { + if actorauthz.Audience != internalauthz.Audience { + t.Errorf("Audience = %q, want %q", actorauthz.Audience, internalauthz.Audience) + } +} + +// TestAccess pins Access to actorscope's values and names, which Authorize +// converts from. +func TestAccess(t *testing.T) { + for _, tc := range []struct { + internal actorscope.Access + public actorauthz.Access + }{ + {actorscope.ReadOnly, actorauthz.ReadOnly}, + {actorscope.ReadWrite, actorauthz.ReadWrite}, + {actorscope.Access(7), actorauthz.Access(7)}, + } { + if actorauthz.Access(tc.internal) != tc.public || tc.public.String() != tc.internal.String() { + t.Errorf("Access %d %q, want %d %q", tc.public, tc.public, tc.internal, tc.internal) + } + } +}