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
6 changes: 3 additions & 3 deletions cmd/ate-setup/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ type Config struct {
// follows neither form.
ExpectedJWTIssuer string

// APIAuthzBootstrapOwners is the comma-separated principal list granted
// owner on global:root (ATE_API_AUTHZ_BOOTSTRAP_OWNERS).
APIAuthzBootstrapOwners string
// APIAuthzBootstrapOwners are the principals granted owner on global:root
// (ATE_API_AUTHZ_BOOTSTRAP_OWNERS, parsed by principal.ParseList).
APIAuthzBootstrapOwners []string

// EgressGatewayExtraSPIFFEIDs hold ate-api-server's egress_gateway role
// next to atenet-egress (ATE_EGRESS_GATEWAY_EXTRA_SPIFFE_IDS).
Expand Down
42 changes: 27 additions & 15 deletions cmd/ate-setup/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,34 +738,46 @@ func TestLoadAPILogLevel(t *testing.T) {
}

func TestLoadAPIAuthz(t *testing.T) {
const (
clientOwner = "system:serviceaccount:ate-system:ate-client"
owners = "alice@example.com,bob@example.com"
)
const clientOwner = "system:serviceaccount:ate-system:ate-client"
for _, tc := range []struct {
name string
env map[string]string
wantOwners string
wantErr bool
wantOwners []string
wantErr string
}{
{
name: "owned by the client ServiceAccount by default",
wantOwners: clientOwner,
wantOwners: []string{clientOwner},
},
{
name: "default owner follows the namespace",
env: map[string]string{"ATE_NAMESPACE": "custom"},
wantOwners: "system:serviceaccount:custom:ate-client",
wantOwners: []string{"system:serviceaccount:custom:ate-client"},
},
{
name: "explicit owners replace the default",
env: map[string]string{"ATE_API_AUTHZ_BOOTSTRAP_OWNERS": owners},
wantOwners: owners,
env: map[string]string{"ATE_API_AUTHZ_BOOTSTRAP_OWNERS": "alice@example.com,bob@example.com"},
wantOwners: []string{"alice@example.com", "bob@example.com"},
},
{
name: "whitespace around owners is ignored",
env: map[string]string{"ATE_API_AUTHZ_BOOTSTRAP_OWNERS": " alice , bob "},
wantOwners: []string{"alice", "bob"},
},
{
name: "an owner that contains a comma is quoted",
env: map[string]string{"ATE_API_AUTHZ_BOOTSTRAP_OWNERS": `"Doe, Jane", bob`},
wantOwners: []string{"Doe, Jane", "bob"},
},
{
name: "only separators",
env: map[string]string{"ATE_API_AUTHZ_BOOTSTRAP_OWNERS": " , "},
wantErr: true,
wantErr: "bootstrap owner",
},
{
name: "an unterminated quote",
env: map[string]string{"ATE_API_AUTHZ_BOOTSTRAP_OWNERS": `"alice`},
wantErr: "ATE_API_AUTHZ_BOOTSTRAP_OWNERS",
},
} {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -774,16 +786,16 @@ func TestLoadAPIAuthz(t *testing.T) {
t.Setenv(name, value)
}
cfg, err := Load(Options{})
if tc.wantErr {
if err == nil || !strings.Contains(err.Error(), "bootstrap owner") {
t.Fatalf("Load() error = %v, want the missing bootstrap owner error", err)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("Load() error = %v, want it to contain %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.APIAuthzBootstrapOwners != tc.wantOwners {
if !slices.Equal(cfg.APIAuthzBootstrapOwners, tc.wantOwners) {
t.Errorf("APIAuthzBootstrapOwners = %q, want %q", cfg.APIAuthzBootstrapOwners, tc.wantOwners)
}
})
Expand Down
14 changes: 10 additions & 4 deletions cmd/ate-setup/internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"go.ate.dev/substrate/internal/actoridjwt"
"go.ate.dev/substrate/internal/installdefaults"
"go.ate.dev/substrate/internal/preview"
"go.ate.dev/substrate/internal/principal"
)

// ConfigPathEnv names the configuration document when --config is not given.
Expand Down Expand Up @@ -207,9 +208,14 @@ func buildConfig(root string, env map[string]string, r *Resolved) (*Config, erro
// tokens minted from the client ServiceAccount, so making it the default
// bootstrap owner keeps them working. The deploy warns whenever it is a
// bootstrap owner.
bootstrapOwners := r.String("ateapi.authz.bootstrapOwners")
if bootstrapOwners == "" {
bootstrapOwners = installdefaults.ClientPrincipal(r.String("namespace"))
bootstrapOwners := []string{installdefaults.ClientPrincipal(r.String("namespace"))}
if raw := r.String("ateapi.authz.bootstrapOwners"); raw != "" {
owners, err := principal.ParseList(raw)
if err != nil {
v, _ := r.Value("ateapi.authz.bootstrapOwners")
return nil, fmt.Errorf("%s: %w (from %s)", v.Setting.Key, err, v.From.Describe(v.Setting))
}
bootstrapOwners = owners
}

cfg := &Config{
Expand Down Expand Up @@ -469,7 +475,7 @@ func validateResolved(cfg *Config, r *Resolved) error {
}
// ate-api-server exits without a bootstrap owner, so catch it before
// deploying.
if strings.Trim(cfg.APIAuthzBootstrapOwners, " ,") == "" {
if len(cfg.APIAuthzBootstrapOwners) == 0 {
owners, _ := r.Value("ateapi.authz.bootstrapOwners")
return &InvalidError{Value: owners, Want: "a list of at least one bootstrap owner"}
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/ate-setup/internal/config/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ var Registry = []Setting{
{
Key: "ateapi.authz.bootstrapOwners", Env: "ATE_API_AUTHZ_BOOTSTRAP_OWNERS",
Flag: "ateapi-authz-bootstrap-owners", Kind: KindString,
Usage: "Comma-separated principals that always hold owner on global:root. " +
Usage: "Comma-separated principals that always hold owner on global:root; " +
`quote a principal that contains a comma, as in '"Doe, Jane",bob'. ` +
"Unset means kubectl-ate's ServiceAccount, system:serviceaccount:<namespace>:ate-client",
},
{
Expand Down
23 changes: 11 additions & 12 deletions cmd/ate-setup/internal/steps/apienv.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"go.ate.dev/substrate/cmd/ate-setup/internal/log"
"go.ate.dev/substrate/internal/installdefaults"
"go.ate.dev/substrate/internal/preview"
"go.ate.dev/substrate/internal/principal"
"go.ate.dev/substrate/internal/resources"
)

Expand Down Expand Up @@ -169,17 +170,15 @@ func (e *Env) UpdateAPIServerEnvVars(ctx context.Context) error {
// It is the default so demos, benchmarks and e2e work, but everyone who can
// mint its token, including every kubectl-ate user without --token-file, is
// then a global owner.
func sharedBootstrapOwnerWarning(bootstrapOwners, namespace string) string {
func sharedBootstrapOwnerWarning(bootstrapOwners []string, namespace string) string {
shared := installdefaults.ClientPrincipal(namespace)
for owner := range strings.SplitSeq(bootstrapOwners, ",") {
if strings.TrimSpace(owner) == shared {
return fmt.Sprintf("%s is a bootstrap owner, so anyone who can mint a token for it is a global owner of this install. "+
"It is the default so the demos, benchmarks and e2e tests work; for a shared or production install, "+
"set --ateapi-authz-bootstrap-owners (ATE_API_AUTHZ_BOOTSTRAP_OWNERS) to your operators' own principals. "+
"See docs/authorization.md.", shared)
}
if !slices.Contains(bootstrapOwners, shared) {
return ""
}
return ""
return fmt.Sprintf("%s is a bootstrap owner, so anyone who can mint a token for it is a global owner of this install. "+
"It is the default so the demos, benchmarks and e2e tests work; for a shared or production install, "+
"set --ateapi-authz-bootstrap-owners (ATE_API_AUTHZ_BOOTSTRAP_OWNERS) to your operators' own principals. "+
"See docs/authorization.md.", shared)
}

// clusterSnapshotLocation validates location and returns it as ate-api-server's
Expand Down Expand Up @@ -371,10 +370,10 @@ const apiServerAuthzFlag = "--authz-bootstrap-owners"

// setAPIServerAuthzArgs sets ate-api-server's bootstrap owners flag on its
// container in objs, replacing any the manifest already carries.
func setAPIServerAuthzArgs(objs []*unstructured.Unstructured, bootstrapOwners string) error {
func setAPIServerAuthzArgs(objs []*unstructured.Unstructured, bootstrapOwners []string) error {
var values []string
if strings.Trim(bootstrapOwners, " ,") != "" {
values = []string{bootstrapOwners}
if len(bootstrapOwners) > 0 {
values = []string{principal.FormatList(bootstrapOwners)}
}
return setAPIServerFlag(objs, apiServerAuthzFlag, values)
}
Expand Down
42 changes: 32 additions & 10 deletions cmd/ate-setup/internal/steps/apienv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/spf13/pflag"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -648,17 +650,25 @@ spec:
`
for _, tc := range []struct {
name string
owners string
owners []string
want []string
}{
{
name: "replaces the owners",
owners: "alice,bob",
owners: []string{"alice", "bob"},
want: []string{
"--config-file=/run/apiconfig.ate.dev/apiconfig.textproto",
"--authz-bootstrap-owners=alice,bob",
},
},
{
name: "an owner that contains a comma or a quote is quoted",
owners: []string{"Doe, Jane", `say "hi"`, "bob"},
want: []string{
"--config-file=/run/apiconfig.ate.dev/apiconfig.textproto",
`--authz-bootstrap-owners="Doe, Jane","say ""hi""",bob`,
},
},
{
name: "no owners drops the flag",
want: []string{"--config-file=/run/apiconfig.ate.dev/apiconfig.textproto"},
Expand All @@ -680,6 +690,17 @@ spec:
if diff := cmp.Diff(tc.want, argsOf(1)); diff != "" {
t.Errorf("ate-api-server args (-want +got):\n%s", diff)
}
// ate-api-server reads the flag as a StringSlice, so it must come
// back as the owners that were set.
fs := pflag.NewFlagSet("ate-api-server", pflag.ContinueOnError)
fs.ParseErrorsAllowlist.UnknownFlags = true
owners := fs.StringSlice("authz-bootstrap-owners", nil, "")
if err := fs.Parse(argsOf(1)); err != nil {
t.Fatalf("parsing ate-api-server args: %v", err)
}
if diff := cmp.Diff(tc.owners, *owners, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("--authz-bootstrap-owners as ate-api-server parses it (-want +got):\n%s", diff)
}
if diff := cmp.Diff([]string{"--keep"}, argsOf(0)); diff != "" {
t.Errorf("other container's args changed (-want +got):\n%s", diff)
}
Expand All @@ -690,7 +711,7 @@ spec:
if err != nil {
t.Fatalf("DecodeManifestBytes() error = %v", err)
}
if err := setAPIServerAuthzArgs(objs, "alice"); err == nil {
if err := setAPIServerAuthzArgs(objs, []string{"alice"}); err == nil {
t.Error("setAPIServerAuthzArgs() without deployment/ate-api-server error = nil, want an error")
}
}
Expand Down Expand Up @@ -757,7 +778,7 @@ func TestCreateAPIServerEnvVarsOmitsAuthz(t *testing.T) {
TransferGSAName: testTransferGSAName, ProjectID: testProjectID,
PostgresReadWriteRole: config.DefaultPostgresReadWriteRole,
PostgresOwnerRole: config.DefaultPostgresOwnerRole,
APIAuthzBootstrapOwners: "system:serviceaccount:ate-system:ate-client",
APIAuthzBootstrapOwners: []string{"system:serviceaccount:ate-system:ate-client"},
SnapshotLocation: "gs://ate-snapshots/",
}, Kube: fakeKube(t,
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: NamespaceAteSystem}},
Expand All @@ -782,14 +803,15 @@ func TestSharedBootstrapOwnerWarning(t *testing.T) {
const shared = "system:serviceaccount:ate-system:ate-client"
for _, tc := range []struct {
name string
owners string
owners []string
warn bool
}{
{name: "default owner", owners: shared, warn: true},
{name: "listed among others", owners: "alice, " + shared + " ,bob", warn: true},
{name: "own principals only", owners: "alice,bob", warn: false},
{name: "another namespace's client", owners: "system:serviceaccount:other:ate-client", warn: false},
{name: "prefix is not a match", owners: shared + "-x", warn: false},
{name: "default owner", owners: []string{shared}, warn: true},
{name: "listed among others", owners: []string{"alice", shared, "bob"}, warn: true},
{name: "own principals only", owners: []string{"alice", "bob"}, warn: false},
{name: "another namespace's client", owners: []string{"system:serviceaccount:other:ate-client"}, warn: false},
{name: "prefix is not a match", owners: []string{shared + "-x"}, warn: false},
{name: "part of a principal that contains a comma", owners: []string{"alice," + shared}, warn: false},
} {
t.Run(tc.name, func(t *testing.T) {
got := sharedBootstrapOwnerWarning(tc.owners, "ate-system")
Expand Down
4 changes: 4 additions & 0 deletions docs/guides/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ rolls `ate-api-server`.
|---|---|---|
| `ateapi.authz.bootstrapOwners` | `ATE_API_AUTHZ_BOOTSTRAP_OWNERS` | `alice@example.com,bob@example.com` |

The owners are a comma-separated list. Put a principal that contains a comma
or a double quote in double quotes, and double any quote inside it:
`"Doe, Jane",bob@example.com`.

If you don't set `ateapi.authz.bootstrapOwners`, `ate-setup` uses the shared
`ate-client` ServiceAccount (`system:serviceaccount:<namespace>:ate-client`),
so the demos, benchmarks and e2e tests work. See the warning at the top of
Expand Down
60 changes: 60 additions & 0 deletions internal/principal/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2026 The Agent Substrate Authors
//
// 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 principal

import (
"bytes"
"encoding/csv"
"fmt"
"strings"
)

// ParseList parses a comma-separated list of principals. It reads CSV, as a
// pflag StringSlice does, so a principal that contains a comma or a double
// quote goes in double quotes, with each embedded quote doubled:
//
// alice, "Doe, Jane", "say ""hi"""
//
// Whitespace around a principal is ignored and empty entries are dropped, so
// an empty list or one of only separators is no principals.
func ParseList(s string) ([]string, error) {
r := csv.NewReader(strings.NewReader(s))
r.TrimLeadingSpace = true
r.FieldsPerRecord = -1
records, err := r.ReadAll()
if err != nil {
return nil, fmt.Errorf("invalid principal list, quote a principal that contains a comma or a double quote: %w", err)
}
var principals []string
for _, record := range records {
for _, p := range record {
if p = strings.TrimSpace(p); p != "" {
principals = append(principals, p)
}
}
}
return principals, nil
}

// FormatList renders principals as a list ParseList and a pflag StringSlice
// read back as the same principals.
func FormatList(principals []string) string {
var b bytes.Buffer
w := csv.NewWriter(&b)
// A bytes.Buffer does not fail to write.
_ = w.Write(principals)
w.Flush()
return strings.TrimSuffix(b.String(), "\n")
}
Loading
Loading