mirror of
https://github.com/siderolabs/omni.git
synced 2025-08-06 17:46:59 +02:00
Some checks failed
default / default (push) Has been cancelled
default / e2e-backups (push) Has been cancelled
default / e2e-cluster-import (push) Has been cancelled
default / e2e-forced-removal (push) Has been cancelled
default / e2e-omni-upgrade (push) Has been cancelled
default / e2e-scaling (push) Has been cancelled
default / e2e-short (push) Has been cancelled
default / e2e-short-secureboot (push) Has been cancelled
default / e2e-templates (push) Has been cancelled
default / e2e-upgrades (push) Has been cancelled
default / e2e-workload-proxy (push) Has been cancelled
The node token status can be used to check if the machine has the unique token generated. It also shows the exact token state: - `NONE` - token is supported, but not yet generated. - `UNSUPPORTED` - Talos is < 1.6.x, so token won't be generated. - `EPHEMERAL` - token is generated, but is not persistent, so join token rotation and machine reboot will make the node to disconnect. - `PERSISTENT` - token is generated and is persisted to the `META` partition. If the node unique status is `NONE` the same controller will try to generate node unique token. Fixes: https://github.com/siderolabs/omni/issues/1348 Signed-off-by: Artem Chernyshev <artem.chernyshev@talos-systems.com>
1442 lines
48 KiB
Go
1442 lines
48 KiB
Go
// Copyright (c) 2025 Sidero Labs, Inc.
|
|
//
|
|
// Use of this software is governed by the Business Source License
|
|
// included in the LICENSE file.
|
|
|
|
//go:build integration
|
|
|
|
package integration_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
pgpcrypto "github.com/ProtonMail/gopenpgp/v2/crypto"
|
|
"github.com/cosi-project/runtime/pkg/controller/generic"
|
|
"github.com/cosi-project/runtime/pkg/resource"
|
|
"github.com/cosi-project/runtime/pkg/resource/meta"
|
|
"github.com/cosi-project/runtime/pkg/resource/rtestutils"
|
|
"github.com/cosi-project/runtime/pkg/safe"
|
|
"github.com/cosi-project/runtime/pkg/state"
|
|
"github.com/google/uuid"
|
|
"github.com/siderolabs/gen/maps"
|
|
"github.com/siderolabs/gen/xslices"
|
|
authcli "github.com/siderolabs/go-api-signature/pkg/client/auth"
|
|
"github.com/siderolabs/go-api-signature/pkg/client/interceptor"
|
|
"github.com/siderolabs/go-api-signature/pkg/pgp"
|
|
"github.com/siderolabs/go-api-signature/pkg/serviceaccount"
|
|
"github.com/siderolabs/go-retry/retry"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap/zapcore"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
|
|
"github.com/siderolabs/omni/client/api/omni/management"
|
|
"github.com/siderolabs/omni/client/api/omni/specs"
|
|
"github.com/siderolabs/omni/client/pkg/access"
|
|
pkgaccess "github.com/siderolabs/omni/client/pkg/access"
|
|
"github.com/siderolabs/omni/client/pkg/client"
|
|
managementcli "github.com/siderolabs/omni/client/pkg/client/management"
|
|
"github.com/siderolabs/omni/client/pkg/constants"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources"
|
|
authres "github.com/siderolabs/omni/client/pkg/omni/resources/auth"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources/k8s"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources/oidc"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources/omni"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources/registry"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources/siderolink"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources/system"
|
|
"github.com/siderolabs/omni/client/pkg/omni/resources/virtual"
|
|
"github.com/siderolabs/omni/internal/backend/runtime/omni/infraprovider"
|
|
"github.com/siderolabs/omni/internal/backend/runtime/omni/validated"
|
|
"github.com/siderolabs/omni/internal/pkg/auth"
|
|
"github.com/siderolabs/omni/internal/pkg/auth/role"
|
|
"github.com/siderolabs/omni/internal/pkg/clientconfig"
|
|
"github.com/siderolabs/omni/internal/pkg/grpcutil"
|
|
)
|
|
|
|
// AssertAnonymousAuthenication tests the authentication without any credentials.
|
|
func AssertAnonymousAuthenication(testCtx context.Context, client *client.Client) TestFunc {
|
|
return func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(testCtx, 300*time.Second)
|
|
defer cancel()
|
|
|
|
ctx = context.WithValue(ctx, interceptor.SkipInterceptorContextKey{}, struct{}{})
|
|
|
|
_, err := client.Omni().State().List(ctx, resource.NewMetadata(resources.DefaultNamespace, omni.ClusterType, "", resource.VersionUndefined))
|
|
assert.Error(t, err)
|
|
|
|
assert.Equalf(t, codes.Unauthenticated, status.Code(err), "%s != %s", codes.Unauthenticated, status.Code(err))
|
|
}
|
|
}
|
|
|
|
// AssertAPIInvalidSignature tests the authentication with invalid credentials.
|
|
func AssertAPIInvalidSignature(testCtx context.Context, client *client.Client) TestFunc {
|
|
return func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(testCtx, 300*time.Second)
|
|
defer cancel()
|
|
|
|
ctx = context.WithValue(ctx, interceptor.SkipInterceptorContextKey{}, struct{}{})
|
|
|
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-sidero-signature", "invalid")
|
|
|
|
_, err := client.Omni().State().List(ctx, resource.NewMetadata(resources.DefaultNamespace, omni.ClusterType, "", resource.VersionUndefined))
|
|
assert.Error(t, err)
|
|
|
|
assert.Equal(t, codes.Unauthenticated, status.Code(err))
|
|
}
|
|
}
|
|
|
|
// AssertPublicKeyWithoutLifetimeNotRegistered tests the registration of a public key without a lifetime.
|
|
func AssertPublicKeyWithoutLifetimeNotRegistered(testCtx context.Context, cli *client.Client) TestFunc {
|
|
return func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(testCtx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
ctx = context.WithValue(ctx, interceptor.SkipInterceptorContextKey{}, struct{}{})
|
|
|
|
email := "test-user-invalid@siderolabs.com"
|
|
|
|
key, err := pgpcrypto.GenerateKey("", email, "x25519", 0)
|
|
require.NoError(t, err)
|
|
|
|
armored, err := key.GetArmoredPublicKey()
|
|
require.NoError(t, err)
|
|
|
|
_, err = cli.Auth().RegisterPGPPublicKey(ctx, email, []byte(armored))
|
|
assert.ErrorContains(t, err, "key does not contain a valid key lifetime")
|
|
}
|
|
}
|
|
|
|
// AssertPublicKeyWithLongLifetimeNotRegistered tests the registration of a public key
|
|
// with a longer than maximum allowed lifetime.
|
|
func AssertPublicKeyWithLongLifetimeNotRegistered(testCtx context.Context, cli *client.Client) TestFunc {
|
|
return func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(testCtx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
ctx = context.WithValue(ctx, interceptor.SkipInterceptorContextKey{}, struct{}{})
|
|
|
|
email := "test-user-invalid@siderolabs.com"
|
|
|
|
key, err := pgp.GenerateKey("", "", email, 9*time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
armored, err := key.ArmorPublic()
|
|
require.NoError(t, err)
|
|
|
|
_, err = cli.Auth().RegisterPGPPublicKey(ctx, email, []byte(armored))
|
|
assert.ErrorContains(t, err, "key lifetime is too long")
|
|
}
|
|
}
|
|
|
|
// AssertRegisterPublicKeyWithUnknownEmail tests the registration of a public key with an unknown email.
|
|
// It should not fail explicitly to avoid leaking information about registered emails.
|
|
func AssertRegisterPublicKeyWithUnknownEmail(testCtx context.Context, cli *client.Client) TestFunc {
|
|
return func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(testCtx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
ctx = context.WithValue(ctx, interceptor.SkipInterceptorContextKey{}, struct{}{})
|
|
|
|
email := "test-user-unknown@siderolabs.com"
|
|
|
|
key, err := pgp.GenerateKey("", "", email, 4*time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
armored, err := key.ArmorPublic()
|
|
require.NoError(t, err)
|
|
|
|
_, err = cli.Auth().RegisterPGPPublicKey(ctx, email, []byte(armored))
|
|
|
|
// an explicit error must not be returned to avoid leaking information
|
|
assert.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
// AssertServiceAccountAPIFlow tests the service account lifecycle and API calls using it.
|
|
func AssertServiceAccountAPIFlow(testCtx context.Context, cli *client.Client) TestFunc {
|
|
return func(t *testing.T) {
|
|
name := "test-" + uuid.NewString()
|
|
|
|
saCli, armoredPublicKey, err := newServiceAccountClient(cli, name)
|
|
require.NoError(t, err)
|
|
|
|
defer saCli.Close() //nolint:errcheck
|
|
|
|
// create service account with the generated key
|
|
_, err = cli.Management().CreateServiceAccount(testCtx, name, armoredPublicKey, string(role.None), true)
|
|
assert.NoError(t, err)
|
|
|
|
// make an API call using the registered service account
|
|
_, err = saCli.Omni().State().List(testCtx, resource.NewMetadata(resources.DefaultNamespace, omni.ClusterType, "", resource.VersionUndefined))
|
|
assert.NoError(t, err)
|
|
|
|
// renew service account
|
|
renewedSACli, renewedArmoredPublicKey, err := newServiceAccountClient(cli, name)
|
|
require.NoError(t, err)
|
|
|
|
defer renewedSACli.Close() //nolint:errcheck
|
|
|
|
_, err = cli.Management().RenewServiceAccount(testCtx, name, renewedArmoredPublicKey)
|
|
assert.NoError(t, err)
|
|
|
|
// make an API call using the renewed service account
|
|
_, err = renewedSACli.Omni().State().List(testCtx, resource.NewMetadata(resources.DefaultNamespace, omni.ClusterType, "", resource.VersionUndefined))
|
|
assert.NoError(t, err)
|
|
|
|
rtestutils.AssertResources(testCtx, t, cli.Omni().State(), []string{
|
|
name + pkgaccess.ServiceAccountNameSuffix,
|
|
}, func(res *authres.ServiceAccountStatus, assert *assert.Assertions) {
|
|
assert.Equal(string(role.Admin), res.TypedSpec().Value.Role)
|
|
assert.Equal(2, len(res.TypedSpec().Value.PublicKeys))
|
|
})
|
|
|
|
// list service accounts and ensure that the created service account is present
|
|
saList, err := cli.Management().ListServiceAccounts(testCtx)
|
|
assert.NoError(t, err)
|
|
|
|
filtered := xslices.Filter(saList, func(sa *management.ListServiceAccountsResponse_ServiceAccount) bool {
|
|
return sa.Name == name
|
|
})
|
|
|
|
assert.Len(t, filtered, 1, "service account not found")
|
|
|
|
// assert service account properties
|
|
foundSA := filtered[0]
|
|
|
|
// expect 2 keys: 1 from the creation and 1 from renewal
|
|
require.Len(t, foundSA.PgpPublicKeys, 2, "unexpected number of PGP public keys")
|
|
|
|
assertTime := func(t *testing.T, expected, actual time.Time, allowedSkew time.Duration) bool {
|
|
return assert.True(t, actual.After(expected.Add(-allowedSkew)) && actual.Before(expected.Add(allowedSkew)))
|
|
}
|
|
|
|
for _, pgpPublicKey := range foundSA.PgpPublicKeys {
|
|
assertTime(t, pgpPublicKey.Expiration.AsTime(), time.Now().Add(auth.ServiceAccountMaxAllowedLifetime), 1*time.Minute)
|
|
}
|
|
|
|
assert.Equal(t, string(role.Admin), foundSA.Role)
|
|
|
|
// destroy service account
|
|
err = cli.Management().DestroyServiceAccount(testCtx, name)
|
|
assert.NoError(t, err)
|
|
|
|
// list service accounts and ensure that the deleted service account is no more present
|
|
saList, err = cli.Management().ListServiceAccounts(testCtx)
|
|
assert.NoError(t, err)
|
|
|
|
assert.False(t, slices.ContainsFunc(saList, func(sa *management.ListServiceAccountsResponse_ServiceAccount) bool {
|
|
return sa.Name == name
|
|
}))
|
|
}
|
|
}
|
|
|
|
func newServiceAccountClient(cli *client.Client, name string) (*client.Client, string, error) {
|
|
// generate a new PGP key with long lifetime
|
|
comment := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
|
|
|
|
serviceAccountEmail := name + pkgaccess.ServiceAccountNameSuffix
|
|
|
|
key, err := pgp.GenerateKey(name, comment, serviceAccountEmail, auth.ServiceAccountMaxAllowedLifetime)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
armoredPublicKey, err := key.ArmorPublic()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
encodedServiceAccount, err := serviceaccount.Encode(name, key)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
interceptors := interceptor.New(interceptor.Options{
|
|
ClientName: "omni-test",
|
|
ServiceAccountBase64: encodedServiceAccount,
|
|
})
|
|
|
|
// create a new API client with the service account PGP signing interceptors
|
|
saCli, err := client.New(
|
|
cli.Endpoint(),
|
|
client.WithGrpcOpts(
|
|
grpc.WithUnaryInterceptor(interceptors.Unary()),
|
|
grpc.WithStreamInterceptor(interceptors.Stream()),
|
|
),
|
|
)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
return saCli, armoredPublicKey, nil
|
|
}
|
|
|
|
type apiAuthzTestCase struct {
|
|
namePrefix string
|
|
fn func(context.Context, *client.Client) error
|
|
assertSuccess func(*testing.T, error)
|
|
assertFailure func(*testing.T, error)
|
|
requiredRole role.Role
|
|
isPublic bool
|
|
}
|
|
|
|
// AssertAPIAuthz tests the authorization checks of the API endpoints.
|
|
//
|
|
//nolint:gocognit,gocyclo,cyclop,maintidx
|
|
func AssertAPIAuthz(rootCtx context.Context, rootCli *client.Client, clientConfig *clientconfig.ClientConfig, clusterName string) TestFunc {
|
|
rootCtx = metadata.NewOutgoingContext(rootCtx, metadata.Pairs(grpcutil.LogLevelOverrideMetadataKey, zapcore.PanicLevel.String()))
|
|
|
|
assertSuccess := func(t *testing.T, err error) {
|
|
expectedConditions := err == nil ||
|
|
state.IsNotFoundError(err) ||
|
|
state.IsConflictError(err) ||
|
|
validated.IsValidationError(err)
|
|
|
|
assert.Truef(t, expectedConditions, "unexpected error: %v", err)
|
|
}
|
|
|
|
assertMissingRoleFailure := func(t *testing.T, err error) {
|
|
correctErrorType := status.Code(err) == codes.PermissionDenied || state.IsOwnerConflictError(err)
|
|
|
|
assert.Truef(t, correctErrorType, "unexpected error: %v", err)
|
|
assert.ErrorContainsf(t, err, "insufficient role", "unexpected error: %v", err)
|
|
}
|
|
|
|
return func(t *testing.T) {
|
|
testCases := []apiAuthzTestCase{
|
|
// Management API tests - global
|
|
{
|
|
namePrefix: "mgmt-talosconfig",
|
|
requiredRole: role.Reader,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Management().Talosconfig(ctx)
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "mgmt-create-service-account",
|
|
requiredRole: role.Admin,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Management().CreateServiceAccount(ctx, "doesntmatter", "doesntmatter", string(role.None), true)
|
|
|
|
// ignore the armored pgp key parse error
|
|
if err != nil && strings.Contains(err.Error(), "no armored data found") {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "mgmt-renew-service-account",
|
|
requiredRole: role.Admin,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Management().RenewServiceAccount(ctx, "doesntmatter", "doesntmatter")
|
|
|
|
// ignore "identity not found" error
|
|
if err != nil && strings.Contains(err.Error(), "doesn't exist") {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "mgmt-list-service-accounts",
|
|
requiredRole: role.Admin,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Management().ListServiceAccounts(ctx)
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "mgmt-destroy-service-account",
|
|
requiredRole: role.Admin,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
err := cli.Management().DestroyServiceAccount(ctx, "doesntmatter")
|
|
|
|
// ignore "service account not found" error
|
|
if status.Code(err) == codes.NotFound {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "mgmt-logs",
|
|
requiredRole: role.Reader,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
machineIDs := rtestutils.ResourceIDs[*omni.Machine](rootCtx, t, rootCli.Omni().State())
|
|
|
|
require.NotEmpty(t, machineIDs)
|
|
|
|
randomMachineID := machineIDs[0]
|
|
|
|
reader, err := cli.Management().LogsReader(ctx, randomMachineID, false, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
buffer := make([]byte, 1)
|
|
_, err = reader.Read(buffer)
|
|
|
|
if status.Code(err) != codes.NotFound && !errors.Is(err, io.EOF) {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
},
|
|
},
|
|
// Management API tests - cluster-specific
|
|
{
|
|
namePrefix: "mgmt-cluster-kubeconfig-user",
|
|
requiredRole: role.Reader,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Management().WithCluster(clusterName).Kubeconfig(ctx)
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "mgmt-cluster-kubeconfig-service-account",
|
|
requiredRole: role.Operator,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Management().WithCluster(clusterName).Kubeconfig(
|
|
ctx,
|
|
managementcli.WithServiceAccount(24*time.Hour, "authz-integration-test", constants.DefaultAccessGroup),
|
|
)
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "mgmt-cluster-talosconfig",
|
|
requiredRole: role.Reader,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Management().WithCluster(clusterName).Talosconfig(ctx)
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "talos-version",
|
|
requiredRole: role.Reader,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Talos().WithCluster(clusterName).Version(ctx, &emptypb.Empty{})
|
|
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
namePrefix: "talos-etcd-status",
|
|
requiredRole: role.Reader,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: func(t *testing.T, err error) {
|
|
assert.Truef(t, status.Code(err) == codes.PermissionDenied, "unexpected error: %v", err)
|
|
},
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.Talos().WithCluster(clusterName).EtcdStatus(ctx, &emptypb.Empty{})
|
|
|
|
return err
|
|
},
|
|
},
|
|
// OIDC API tests
|
|
{
|
|
namePrefix: "oidc-authenticate",
|
|
requiredRole: role.None,
|
|
assertSuccess: assertSuccess,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
_, err := cli.OIDC().Authenticate(ctx, "test-token")
|
|
|
|
// silence the error on 'token not found'
|
|
if errStatus, ok := status.FromError(err); ok {
|
|
if errStatus.Code() == codes.PermissionDenied && errStatus.Message() == "failed to authenticate request: request not found" {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return err
|
|
},
|
|
},
|
|
// Audit log
|
|
{
|
|
namePrefix: "audit-logs",
|
|
requiredRole: role.Admin,
|
|
assertSuccess: assertSuccess,
|
|
assertFailure: assertMissingRoleFailure,
|
|
fn: func(ctx context.Context, cli *client.Client) error {
|
|
for _, err := range cli.Management().ReadAuditLog(ctx, "", "") {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
// test each test case without signature
|
|
t.Run(fmt.Sprintf("%s-no-signature", tc.namePrefix), func(t *testing.T) {
|
|
scopedClient, testErr := clientConfig.GetClient(rootCtx)
|
|
require.NoError(t, testErr)
|
|
|
|
// skip signing the request
|
|
ctx := context.WithValue(rootCtx, interceptor.SkipInterceptorContextKey{}, struct{}{})
|
|
|
|
testErr = tc.fn(ctx, scopedClient)
|
|
|
|
// public resources will either succeed or fail with a permission denied if they are read-only resources
|
|
if tc.isPublic {
|
|
expectedCondition := testErr == nil || strings.Contains(testErr.Error(), "only read access is permitted")
|
|
|
|
assert.Truef(t, expectedCondition, "error did not meet condition: %v", testErr)
|
|
|
|
return
|
|
}
|
|
|
|
// protected resources must always fail with unauthenticated
|
|
|
|
expectedCondition := testErr != nil && strings.Contains(testErr.Error(), "Unauthenticated")
|
|
|
|
assert.Truef(t, expectedCondition, "error did not meet condition: %v", testErr)
|
|
})
|
|
|
|
// public resources are not subject to role restrictions
|
|
if tc.isPublic {
|
|
continue
|
|
}
|
|
|
|
// test with the role which should succeed
|
|
t.Run(fmt.Sprintf("%s-success", tc.namePrefix), func(t *testing.T) {
|
|
scopedClient, testErr := clientConfig.GetClient(
|
|
rootCtx,
|
|
authcli.WithRole(string(tc.requiredRole)),
|
|
authcli.WithSkipUserRole(true),
|
|
)
|
|
require.NoError(t, testErr)
|
|
|
|
assertCurrentUserRole(rootCtx, t, scopedClient.Omni().State(), tc.requiredRole)
|
|
|
|
testErr = tc.fn(rootCtx, scopedClient)
|
|
|
|
tc.assertSuccess(t, testErr)
|
|
})
|
|
|
|
if tc.assertFailure == nil || tc.requiredRole == role.None {
|
|
continue
|
|
}
|
|
|
|
// test with the role which should fail
|
|
|
|
var err error
|
|
|
|
// one less than the required role
|
|
failureRole, err := tc.requiredRole.Previous()
|
|
require.NoError(t, err)
|
|
|
|
t.Run(fmt.Sprintf("%s-failure", tc.namePrefix), func(t *testing.T) {
|
|
scopedClient, testErr := clientConfig.GetClient(
|
|
rootCtx,
|
|
authcli.WithRole(string(failureRole)),
|
|
authcli.WithSkipUserRole(true))
|
|
require.NoError(t, testErr)
|
|
|
|
assertCurrentUserRole(rootCtx, t, scopedClient.Omni().State(), failureRole)
|
|
|
|
testErr = tc.fn(rootCtx, scopedClient)
|
|
|
|
tc.assertFailure(t, testErr)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
type resourceAuthzTestCase struct {
|
|
resource resource.Resource
|
|
allowedVerbSet map[state.Verb]struct{}
|
|
isAdminOnly bool
|
|
isSignatureSufficient bool
|
|
isPublic bool
|
|
isDestroyNotAllowed bool
|
|
}
|
|
|
|
// AssertResourceAuthz tests the authorization checks of the resources (state).
|
|
//
|
|
//nolint:gocognit,gocyclo,cyclop,maintidx
|
|
func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, clientConfig *clientconfig.ClientConfig) TestFunc {
|
|
rootCtx = metadata.NewOutgoingContext(rootCtx, metadata.Pairs(grpcutil.LogLevelOverrideMetadataKey, zapcore.PanicLevel.String()))
|
|
|
|
return func(t *testing.T) {
|
|
allRoles := []role.Role{role.None, role.Reader, role.Operator, role.Admin}
|
|
allVerbs := []state.Verb{state.Get, state.List, state.Create, state.Update, state.Destroy}
|
|
|
|
allVerbsSet := xslices.ToSet(allVerbs)
|
|
readOnlyVerbSet := xslices.ToSet([]state.Verb{state.Get, state.List})
|
|
|
|
// fully client-managed resources
|
|
|
|
identity := authres.NewIdentity(resources.DefaultNamespace, uuid.New().String())
|
|
accessPolicy := authres.NewAccessPolicy()
|
|
samlLabelRule := authres.NewSAMLLabelRule(resources.DefaultNamespace, uuid.New().String())
|
|
cluster := omni.NewCluster(resources.DefaultNamespace, uuid.New().String())
|
|
cluster.TypedSpec().Value.TalosVersion = "1.2.2"
|
|
configPatch := omni.NewConfigPatch(resources.DefaultNamespace, uuid.New().String())
|
|
machineLabels := omni.NewMachineLabels(resources.DefaultNamespace, uuid.New().String())
|
|
machineSet := omni.NewMachineSet(resources.DefaultNamespace, uuid.New().String())
|
|
machineSet.Metadata().Labels().Set(omni.LabelCluster, cluster.Metadata().ID())
|
|
machineSetNode := omni.NewMachineSetNode(resources.DefaultNamespace, uuid.New().String(), machineSet)
|
|
machineClass := omni.NewMachineClass(resources.DefaultNamespace, uuid.New().String())
|
|
machineRequestSet := omni.NewMachineRequestSet(resources.DefaultNamespace, uuid.New().String())
|
|
infraMachineConfig := omni.NewInfraMachineConfig(resources.DefaultNamespace, uuid.New().String())
|
|
|
|
extensionsConfiguration := omni.NewExtensionsConfiguration(resources.DefaultNamespace, uuid.New().String())
|
|
extensionsConfiguration.Metadata().Labels().Set(omni.LabelCluster, cluster.Metadata().ID())
|
|
|
|
machineExtensions := omni.NewMachineExtensions(resources.DefaultNamespace, uuid.New().String())
|
|
machineExtensions.Metadata().Labels().Set(omni.LabelCluster, uuid.New().String())
|
|
|
|
machineExtensionsStatus := omni.NewMachineExtensionsStatus(resources.DefaultNamespace, uuid.New().String())
|
|
machineExtensionsStatus.Metadata().Labels().Set(omni.LabelCluster, uuid.New().String())
|
|
|
|
joinToken := siderolink.NewJoinToken(resources.DefaultNamespace, uuid.New().String())
|
|
|
|
defaultJoinToken, err := safe.StateGetByID[*siderolink.DefaultJoinToken](rootCtx, rootCli.Omni().State(), siderolink.DefaultJoinTokenID)
|
|
|
|
require.NoError(t, err)
|
|
|
|
importedClusterSecret := omni.NewImportedClusterSecrets(resources.DefaultNamespace, cluster.Metadata().ID())
|
|
|
|
testCases := []resourceAuthzTestCase{
|
|
{
|
|
resource: identity,
|
|
allowedVerbSet: allVerbsSet,
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: authres.NewUser(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: allVerbsSet,
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: accessPolicy,
|
|
allowedVerbSet: allVerbsSet,
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: joinToken,
|
|
allowedVerbSet: xslices.ToSet([]state.Verb{state.Get, state.List, state.Update, state.Destroy}),
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: defaultJoinToken,
|
|
allowedVerbSet: allVerbsSet,
|
|
isAdminOnly: true,
|
|
isDestroyNotAllowed: true,
|
|
},
|
|
{
|
|
resource: samlLabelRule,
|
|
allowedVerbSet: allVerbsSet,
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: omni.NewInfraMachineBMCConfig(uuid.New().String()),
|
|
allowedVerbSet: allVerbsSet,
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: cluster,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: configPatch,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: machineLabels,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: machineSet,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: machineSetNode,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: omni.NewNodeForceDestroyRequest(uuid.New().String()),
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: machineClass,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: machineRequestSet,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: infraMachineConfig,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: omni.NewEtcdManualBackup(uuid.New().String()),
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: omni.NewEtcdBackupS3Conf(),
|
|
allowedVerbSet: allVerbsSet,
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: extensionsConfiguration,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: machineExtensions,
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: machineExtensionsStatus,
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: importedClusterSecret,
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
}
|
|
|
|
// read-only resources
|
|
|
|
resourceDefinition, err := meta.NewResourceDefinition(meta.ResourceDefinitionSpec{
|
|
Type: "Tests.cosi.dev",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
testCases = append(testCases, []resourceAuthzTestCase{
|
|
{
|
|
resource: resourceDefinition,
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: meta.NewNamespace("test", meta.NamespaceSpec{}),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewClusterBootstrapStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterDestroyStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterEndpoint(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterKubernetesNodes(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineIdentity(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineConfigPatches(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineTalosVersion(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachine(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineRequestStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineTemplate(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterDiagnostics(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterUUID(uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewClusterWorkloadProxyStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewKubernetesNodeAuditResult(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewEtcdBackup(uuid.New().String(), time.Now()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewControlPlaneStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewExposedService(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewFeaturesConfig(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewKubernetesStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: virtual.NewKubernetesUsage(resources.MetricsNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: virtual.NewLabelsCompletion(resources.MetricsNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewKubernetesUpgradeManifestStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewKubernetesUpgradeStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewLoadBalancerConfig(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewLoadBalancerStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachine(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineSetStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineStatusLink(resources.MetricsNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineStatusSnapshot(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineConfigGenOptions(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewOngoingTask(resources.DefaultNamespace, "res"),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewKubernetesVersion(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewTalosVersion(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewTalosUpgradeStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewInstallationMedia(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewRedactedClusterMachineConfig(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineConfigDiff(uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewImagePullRequest(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewImagePullStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: authres.NewAuthConfig(),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isPublic: true,
|
|
},
|
|
{
|
|
resource: siderolink.NewConnectionParams(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: system.NewSysVersion(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: virtual.NewCurrentUser(),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: virtual.NewAdvertisedEndpoints(),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: virtual.NewClusterPermissions(uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: virtual.NewPermissions(),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewEtcdBackupStatus(uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewEtcdBackupOverallStatus(),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineSetDestroyStatus(resources.EphemeralNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewEtcdBackupStoreStatus(),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewSchematic(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewTalosExtensions(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewSchematicConfiguration(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewExtensionsConfigurationStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineStatusMetrics(resources.EphemeralNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewClusterStatusMetrics(resources.EphemeralNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isSignatureSufficient: true,
|
|
},
|
|
{
|
|
resource: omni.NewClusterTaint(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: system.NewResourceLabels[*omni.MachineStatus](uuid.New().String()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: siderolink.NewLinkStatus(siderolink.NewLink(resources.DefaultNamespace, uuid.NewString(), nil)),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewMachineRequestSetStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
allowedVerbSet: allVerbsSet,
|
|
},
|
|
{
|
|
resource: omni.NewMaintenanceConfigStatus(uuid.NewString()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: omni.NewDiscoveryAffiliateDeleteTask(uuid.NewString()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: authres.NewServiceAccountStatus(uuid.NewString()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
isAdminOnly: true,
|
|
},
|
|
{
|
|
resource: omni.NewInfraProviderCombinedStatus(uuid.NewString()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: siderolink.NewAPIConfig(),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: siderolink.NewJoinTokenStatus(resources.DefaultNamespace, uuid.NewString()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
{
|
|
resource: siderolink.NewNodeUniqueTokenStatus(uuid.NewString()),
|
|
allowedVerbSet: readOnlyVerbSet,
|
|
},
|
|
}...)
|
|
|
|
// no access resources
|
|
testCases = append(testCases, []resourceAuthzTestCase{
|
|
{
|
|
resource: omni.NewClusterConfigVersion(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: oidc.NewJWTPublicKey(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: system.NewDBVersion(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: system.NewCertRefreshTick(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: authres.NewPublicKey(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineConfigStatus(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewEtcdAuditResult(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewKubeconfig(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewTalosConfig(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: siderolink.NewConfig(),
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineConfig(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewClusterSecrets(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: authres.NewSAMLAssertion(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewClusterMachineEncryptionKey(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewEtcdBackupEncryption(resources.DefaultNamespace, uuid.New().String()),
|
|
},
|
|
{
|
|
resource: omni.NewBackupData(uuid.New().String()),
|
|
},
|
|
{
|
|
resource: siderolink.NewPendingMachineStatus(uuid.NewString()),
|
|
},
|
|
{
|
|
resource: siderolink.NewMachineJoinConfig(uuid.NewString()),
|
|
},
|
|
{
|
|
resource: siderolink.NewJoinTokenUsage(uuid.NewString()),
|
|
},
|
|
{
|
|
resource: siderolink.NewNodeUniqueToken(uuid.NewString()),
|
|
},
|
|
}...)
|
|
|
|
// custom resources
|
|
|
|
testCases = append(testCases, []resourceAuthzTestCase{
|
|
{
|
|
resource: siderolink.NewLink(resources.DefaultNamespace, uuid.New().String(), &specs.SiderolinkSpec{}),
|
|
allowedVerbSet: xslices.ToSet([]state.Verb{state.Get, state.List, state.Update, state.Destroy}),
|
|
},
|
|
{
|
|
resource: siderolink.NewPendingMachine(uuid.New().String(), &specs.SiderolinkSpec{}),
|
|
allowedVerbSet: xslices.ToSet([]state.Verb{state.Get, state.List, state.Update, state.Destroy}),
|
|
},
|
|
}...)
|
|
|
|
untestedResourceTypes := xslices.ToSetFunc(registry.Resources, func(rd generic.ResourceWithRD) resource.Type {
|
|
return rd.ResourceDefinition().Type
|
|
})
|
|
|
|
// infra provider resources have their custom authz logic, they are unit-tested in their package, exclude them
|
|
untestedResourceTypes = maps.Filter(untestedResourceTypes, func(resourceType resource.Type, _ struct{}) bool {
|
|
return !infraprovider.IsInfraProviderResource(resources.InfraProviderNamespace, resourceType)
|
|
})
|
|
|
|
// delete excluded resources from the untested set
|
|
delete(untestedResourceTypes, k8s.KubernetesResourceType)
|
|
delete(untestedResourceTypes, siderolink.DeprecatedLinkCounterType)
|
|
|
|
for _, tc := range testCases {
|
|
for _, testVerb := range allVerbs {
|
|
for _, testRole := range allRoles {
|
|
name := fmt.Sprintf("resource-authz-%s-%s-%s", testRole, tc.resource.Metadata().Type(), verbToString(testVerb))
|
|
|
|
// delete the resource type from the untested set
|
|
delete(untestedResourceTypes, tc.resource.Metadata().Type())
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
scopedCli, testErr := clientConfig.GetClient(
|
|
rootCtx,
|
|
authcli.WithRole(string(testRole)),
|
|
authcli.WithSkipUserRole(true),
|
|
)
|
|
require.NoError(t, testErr)
|
|
|
|
// ensure that scopedCli is operating with the correct role
|
|
assertCurrentUserRole(rootCtx, t, scopedCli.Omni().State(), testRole)
|
|
|
|
noSignatureCtx := context.WithValue(rootCtx, interceptor.SkipInterceptorContextKey{}, struct{}{})
|
|
|
|
accessErr := accessResource(noSignatureCtx, t, rootCli, scopedCli, tc.resource, testVerb)
|
|
|
|
if len(tc.allowedVerbSet) == 0 {
|
|
assert.ErrorContains(t, accessErr, "no access is permitted")
|
|
|
|
return
|
|
}
|
|
|
|
if !tc.isPublic {
|
|
assert.ErrorContains(t, accessErr, "missing valid signature")
|
|
|
|
// refresh the error but with a signature this time
|
|
accessErr = accessResource(rootCtx, t, rootCli, scopedCli, tc.resource, testVerb)
|
|
}
|
|
|
|
isVerbError := accessErr != nil && strings.Contains(accessErr.Error(), "only") && strings.Contains(accessErr.Error(), "access is permitted")
|
|
isRoleError := accessErr != nil && strings.Contains(accessErr.Error(), "insufficient role:")
|
|
|
|
// assert the error
|
|
|
|
isReader := testRole.Check(role.Reader) == nil
|
|
isOperator := testRole.Check(role.Operator) == nil
|
|
isAdmin := testRole.Check(role.Admin) == nil
|
|
_, verbAllowed := tc.allowedVerbSet[testVerb]
|
|
sufficientRole := true
|
|
|
|
if tc.isAdminOnly {
|
|
if !isAdmin {
|
|
sufficientRole = false
|
|
}
|
|
} else {
|
|
if testVerb.Readonly() {
|
|
if !isReader {
|
|
sufficientRole = false
|
|
}
|
|
} else {
|
|
if !isOperator {
|
|
sufficientRole = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if tc.isSignatureSufficient || tc.isPublic {
|
|
sufficientRole = true
|
|
}
|
|
|
|
switch {
|
|
case !verbAllowed && !sufficientRole:
|
|
// we check for either of these errors because their order is not guaranteed
|
|
assert.Truef(t, isVerbError || isRoleError, "expected verb not allowed or insufficient role error, got: %v", accessErr)
|
|
case !verbAllowed:
|
|
assert.Truef(t, isVerbError, "expected verb not allowed error, got: %v", accessErr)
|
|
case !sufficientRole:
|
|
assert.Truef(t, isRoleError, "expected insufficient role error, got: %v", accessErr)
|
|
default:
|
|
if accessErr != nil {
|
|
toleratedErrors := map[string]string{
|
|
"NotFoundError": "doesn't exist",
|
|
"ValidationError": "failed to validate",
|
|
"UnsupportedError": "unsupported resource type",
|
|
"AlreadyExists(DefaultJoinToken)": "resource DefaultJoinTokens.omni.sidero.dev(default/default@1) already exists",
|
|
"AlreadyExists(AccessPolicy)": "resource AccessPolicies.omni.sidero.dev(default/access-policy@undefined) already exists",
|
|
"VersionConflict(AccessPolicy)": "failed to update: resource AccessPolicies.omni.sidero.dev(default/access-policy@1) update conflict: expected version",
|
|
}
|
|
|
|
isExpectedError := false
|
|
|
|
for _, toleratedErrorSubstring := range toleratedErrors {
|
|
if strings.Contains(accessErr.Error(), toleratedErrorSubstring) {
|
|
isExpectedError = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.Truef(t, isExpectedError, "expected one of: %q, got: %v", maps.Keys(toleratedErrors), accessErr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensure that all resources are tested
|
|
for untestedResourceType := range untestedResourceTypes {
|
|
assert.Failf(t, "resource-authz", "resource type %s is not tested", untestedResourceType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AssertResourceAuthzWithACL tests the authorization checks of with ACLs.
|
|
func AssertResourceAuthzWithACL(ctx context.Context, rootCli *client.Client, clientConfig *clientconfig.ClientConfig) TestFunc {
|
|
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs(grpcutil.LogLevelOverrideMetadataKey, zapcore.PanicLevel.String()))
|
|
|
|
return func(t *testing.T) {
|
|
rootState := rootCli.Omni().State()
|
|
|
|
testID := "acl-test-" + uuid.NewString()
|
|
|
|
saName := "service-account-" + testID
|
|
|
|
key := createServiceAccount(ctx, t, rootCli, saName, role.Reader)
|
|
|
|
identity := saName + access.ServiceAccountNameSuffix
|
|
|
|
t.Cleanup(func() {
|
|
require.NoError(t, rootCli.Management().DestroyServiceAccount(ctx, saName))
|
|
})
|
|
|
|
accessPolicy := authres.NewAccessPolicy()
|
|
|
|
err := rootState.Destroy(ctx, accessPolicy.Metadata())
|
|
require.Truef(t, err == nil || state.IsNotFoundError(err), "unexpected error: %v", err)
|
|
|
|
clusterAuthorizedID := "authorized-" + testID
|
|
|
|
accessPolicy.TypedSpec().Value.Rules = []*specs.AccessPolicyRule{
|
|
{
|
|
Users: []string{identity},
|
|
Clusters: []string{clusterAuthorizedID},
|
|
Role: string(role.Operator),
|
|
},
|
|
}
|
|
|
|
err = rootState.Create(ctx, accessPolicy)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { destroy(ctx, t, rootCli, accessPolicy.Metadata()) })
|
|
|
|
userCli, err := client.New(rootCli.Endpoint(), client.WithServiceAccount(key))
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { userCli.Close() }) //nolint:errcheck
|
|
|
|
clusterUnauthorized := omni.NewCluster(resources.DefaultNamespace, "unauthorized-"+testID)
|
|
clusterUnauthorized.TypedSpec().Value.TalosVersion = constants.DefaultTalosVersion
|
|
clusterUnauthorized.TypedSpec().Value.KubernetesVersion = "1.28.3"
|
|
|
|
userState := userCli.Omni().State()
|
|
|
|
// try to create an unauthorized cluster using the user client
|
|
err = userState.Create(ctx, clusterUnauthorized)
|
|
assert.ErrorContains(t, err, "insufficient role")
|
|
|
|
// create it using the admin client
|
|
require.NoError(t, rootState.Create(ctx, clusterUnauthorized))
|
|
|
|
t.Cleanup(func() { destroy(ctx, t, rootCli, clusterUnauthorized.Metadata()) })
|
|
|
|
// create a cluster that is authorized to the user by the ACL
|
|
clusterAuthorized := omni.NewCluster(resources.DefaultNamespace, clusterAuthorizedID)
|
|
clusterAuthorized.TypedSpec().Value.TalosVersion = constants.DefaultTalosVersion
|
|
clusterAuthorized.TypedSpec().Value.KubernetesVersion = "1.28.3"
|
|
|
|
err = userState.Create(ctx, clusterAuthorized)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { destroy(ctx, t, rootCli, clusterAuthorized.Metadata()) })
|
|
|
|
// try to get the unauthorized cluster using the user client - should work, as the user has the Reader role
|
|
_, err = userState.Get(ctx, clusterUnauthorized.Metadata())
|
|
require.NoError(t, err)
|
|
|
|
// try to modify the unauthorized cluster using the user client
|
|
clusterUnauthorized.TypedSpec().Value.TalosVersion = "1.4.5"
|
|
|
|
err = userState.Update(ctx, clusterUnauthorized)
|
|
assert.ErrorContains(t, err, "insufficient role")
|
|
|
|
// try to get the authorized cluster using the user client
|
|
_, err = userState.Get(ctx, clusterAuthorized.Metadata())
|
|
require.NoError(t, err)
|
|
|
|
// test the logic for a config patch without any cluster association
|
|
configPatchUnauthorized := omni.NewConfigPatch(resources.DefaultNamespace, "unauthorized-"+testID)
|
|
|
|
err = userState.Create(ctx, configPatchUnauthorized)
|
|
assert.ErrorContains(t, err, "insufficient role")
|
|
|
|
// test the logic for a config patch with an authorized cluster association
|
|
configPatchAuthorized := omni.NewConfigPatch(resources.DefaultNamespace, "authorized"+testID)
|
|
configPatchAuthorized.Metadata().Labels().Set(omni.LabelCluster, clusterAuthorized.Metadata().ID())
|
|
|
|
err = configPatchAuthorized.TypedSpec().Value.SetUncompressedData([]byte("debug: true"))
|
|
require.NoError(t, err)
|
|
|
|
err = userState.Create(ctx, configPatchAuthorized)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { destroy(ctx, t, rootCli, configPatchAuthorized.Metadata()) })
|
|
}
|
|
}
|
|
|
|
func accessResource(ctx context.Context, t *testing.T, rootCli *client.Client, cli *client.Client, res resource.Resource, verb state.Verb) error {
|
|
var err error
|
|
|
|
md := res.Metadata()
|
|
|
|
switch verb { //nolint:exhaustive
|
|
case state.List:
|
|
_, err = cli.Omni().State().List(ctx, md)
|
|
case state.Get:
|
|
_, err = cli.Omni().State().Get(ctx, md)
|
|
case state.Destroy:
|
|
err = cli.Omni().State().Destroy(ctx, md)
|
|
case state.Create:
|
|
err = cli.Omni().State().Create(ctx, res)
|
|
if err == nil {
|
|
defer destroy(ctx, t, rootCli, md)
|
|
}
|
|
case state.Update:
|
|
err = cli.Omni().State().Update(ctx, res)
|
|
default:
|
|
assert.Fail(t, "unsupported test verb: %s", verbToString(verb))
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func assertCurrentUserRole(ctx context.Context, t *testing.T, st state.State, expected role.Role) {
|
|
currentUser, currentUserErr := safe.StateGet[*virtual.CurrentUser](ctx, st, virtual.NewCurrentUser().Metadata())
|
|
require.NoError(t, currentUserErr)
|
|
|
|
assert.Equal(t, string(expected), currentUser.TypedSpec().Value.GetRole(), "invalid role on current user virtual resource: %v", currentUser.TypedSpec().Value.GetRole())
|
|
}
|
|
|
|
func destroy(ctx context.Context, t *testing.T, rootClient *client.Client, md *resource.Metadata) {
|
|
t.Logf("destroying created resource %s", md.String())
|
|
|
|
_, err := rootClient.Omni().State().Teardown(ctx, md)
|
|
if state.IsNotFoundError(err) {
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
err = retry.Constant(5*time.Second, retry.WithUnits(100*time.Millisecond)).RetryWithContext(ctx, func(ctx context.Context) error {
|
|
err = rootClient.Omni().State().Destroy(ctx, md)
|
|
if err != nil {
|
|
if state.IsNotFoundError(err) {
|
|
return nil
|
|
}
|
|
|
|
return retry.ExpectedError(err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func verbToString(verb state.Verb) string {
|
|
switch verb {
|
|
case state.List:
|
|
return "list"
|
|
case state.Get:
|
|
return "get"
|
|
case state.Create:
|
|
return "create"
|
|
case state.Update:
|
|
return "update"
|
|
case state.Destroy:
|
|
return "destroy"
|
|
case state.Watch:
|
|
return "watch"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|