Skip to content
Draft
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
9 changes: 5 additions & 4 deletions cmd/objectstore-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 3 additions & 7 deletions cmd/objectstore-plugin/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions hack/verify/package-deps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,5 +77,6 @@ check() {

check ./cmd/ateapi "${ateapi_banned[@]}"
check ./internal/objectstoreplugin/actorauthz "${actorauthz_banned[@]}"
check ./pkg/objectstore/actorauthz "${actorauthz_banned[@]}"

exit "${status}"
81 changes: 81 additions & 0 deletions hack/verify/pkg-external-import.sh
Original file line number Diff line number Diff line change
@@ -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" <<EOF
module example.com/substrate-external-import

go ${go_version}

require go.ate.dev/substrate v0.0.0

replace go.ate.dev/substrate => ${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" <<EOF
package direct

import _ "go.ate.dev/substrate/internal/oidcjwt"
EOF

cd "${tmp}"
export GOFLAGS=-mod=mod GOWORK=off
if ! out="$(go build ./public 2>&1)"; then
echo "${out}" >&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[*]}"
16 changes: 15 additions & 1 deletion internal/objectstoreplugin/actorauthz/actorauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package actorauthz
import (
"context"
"errors"
"fmt"
"slices"
"time"

Expand Down Expand Up @@ -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 {
Expand All @@ -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}
}
Expand Down
22 changes: 21 additions & 1 deletion internal/objectstoreplugin/actorauthz/actorauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
})
}
}
Loading
Loading