diff --git a/.kres.yaml b/.kres.yaml index dd38012f..41808b10 100644 --- a/.kres.yaml +++ b/.kres.yaml @@ -174,7 +174,7 @@ spec: environment: WITH_DEBUG: "true" INTEGRATION_RUN_E2E_TEST: "true" - INTEGRATION_TEST_ARGS: "--test.run CleanState/|Auth/|DefaultCluster/" + INTEGRATION_TEST_ARGS: "--test.run CleanState/|Auth/|DefaultCluster/|CLICommands/" TALEMU_TEST_ARGS: "--test.run ImmediateClusterDestruction/|EncryptedCluster/|SinglenodeCluster/|ScaleUpAndDown/|ScaleUpAndDownMachineClassBasedMachineSets/|TalosUpgrades/|KubernetesUpgrades/|MaintenanceDowngrade/|ClusterTemplate/|ScaleUpAndDownAutoProvisionMachineSets/" RUN_TALEMU_TESTS: true jobs: diff --git a/client/pkg/access/serviceaccount.go b/client/pkg/access/serviceaccount.go index 686a732d..8841c911 100644 --- a/client/pkg/access/serviceaccount.go +++ b/client/pkg/access/serviceaccount.go @@ -9,7 +9,9 @@ import ( ) const ( - serviceAccountDomain = "serviceaccount.omni.sidero.dev" + // tsgen:ServiceAccountDomain + serviceAccountDomain = "serviceaccount.omni.sidero.dev" + // tsgen:InfraProviderServiceAccountDomain infraProviderServiceAccountDomain = "infra-provider." + serviceAccountDomain // ServiceAccountNameSuffix is appended to the name of all service accounts. diff --git a/client/pkg/omni/resources/auth/labels.go b/client/pkg/omni/resources/auth/labels.go index d861f1ab..028e84c8 100644 --- a/client/pkg/omni/resources/auth/labels.go +++ b/client/pkg/omni/resources/auth/labels.go @@ -14,6 +14,7 @@ const ( const ( // LabelPublicKeyUserID is the label that defines the user ID of the public key. + // tsgen:LabelPublicKeyUserID LabelPublicKeyUserID = "user-id" // LabelIdentityUserID is a label linking identity to the user. diff --git a/client/pkg/omnictl/user.go b/client/pkg/omnictl/user.go new file mode 100644 index 00000000..bccf3706 --- /dev/null +++ b/client/pkg/omnictl/user.go @@ -0,0 +1,11 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package omnictl + +import "github.com/siderolabs/omni/client/pkg/omnictl/user" + +func init() { + RootCmd.AddCommand(user.RootCmd()) +} diff --git a/client/pkg/omnictl/user/create.go b/client/pkg/omnictl/user/create.go new file mode 100644 index 00000000..ca9095cb --- /dev/null +++ b/client/pkg/omnictl/user/create.go @@ -0,0 +1,72 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package user + +import ( + "context" + "fmt" + + "github.com/cosi-project/runtime/pkg/safe" + "github.com/cosi-project/runtime/pkg/state" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/siderolabs/omni/client/pkg/client" + "github.com/siderolabs/omni/client/pkg/omni/resources" + "github.com/siderolabs/omni/client/pkg/omni/resources/auth" + "github.com/siderolabs/omni/client/pkg/omnictl/internal/access" +) + +var createCmdFlags struct { + role string +} + +// createCmd represents the user create command. +var createCmd = &cobra.Command{ + Use: "create [email]", + Short: "Create a user.", + Long: `Create a user with the specified email.`, + Example: "", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return access.WithClient(createUser(args[0])) + }, +} + +func createUser(email string) func(ctx context.Context, client *client.Client) error { + return func(ctx context.Context, client *client.Client) error { + user := auth.NewUser(resources.DefaultNamespace, uuid.NewString()) + + user.TypedSpec().Value.Role = createCmdFlags.role + + identity := auth.NewIdentity(resources.DefaultNamespace, email) + + identity.Metadata().Labels().Set(auth.LabelIdentityUserID, user.Metadata().ID()) + + identity.TypedSpec().Value.UserId = user.Metadata().ID() + + existing, err := safe.ReaderGetByID[*auth.Identity](ctx, client.Omni().State(), email) + if err != nil && !state.IsNotFoundError(err) { + return err + } + + if existing != nil { + return fmt.Errorf("identity with email %q already exists", email) + } + + if err := client.Omni().State().Create(ctx, user); err != nil { + return err + } + + return client.Omni().State().Create(ctx, identity) + } +} + +func init() { + createCmd.PersistentFlags().StringVarP(&createCmdFlags.role, "role", "r", "", "Role to use for the user creation") + createCmd.MarkPersistentFlagRequired("role") //nolint:errcheck + + userCmd.AddCommand(createCmd) +} diff --git a/client/pkg/omnictl/user/delete.go b/client/pkg/omnictl/user/delete.go new file mode 100644 index 00000000..f9901238 --- /dev/null +++ b/client/pkg/omnictl/user/delete.go @@ -0,0 +1,77 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package user + +import ( + "context" + "fmt" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/safe" + "github.com/cosi-project/runtime/pkg/state" + "github.com/spf13/cobra" + + "github.com/siderolabs/omni/client/pkg/client" + "github.com/siderolabs/omni/client/pkg/omni/resources" + "github.com/siderolabs/omni/client/pkg/omni/resources/auth" + "github.com/siderolabs/omni/client/pkg/omnictl/internal/access" +) + +// deleteCmd represents the user delete command. +var deleteCmd = &cobra.Command{ + Use: "delete [email1 email2]", + Short: "Delete users.", + Long: `Delete users with the specified emails.`, + Example: "", + Args: cobra.MinimumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return access.WithClient(deleteUsers(args...)) + }, +} + +func deleteUsers(emails ...string) func(ctx context.Context, client *client.Client) error { + return func(ctx context.Context, client *client.Client) error { + toDelete := make([]resource.Pointer, 0, len(emails)*2) + + for _, email := range emails { + identity := auth.NewIdentity(resources.DefaultNamespace, email) + + existing, err := safe.ReaderGetByID[*auth.Identity](ctx, client.Omni().State(), email) + if err != nil { + return err + } + + toDelete = append(toDelete, identity.Metadata(), auth.NewUser(resources.DefaultNamespace, existing.TypedSpec().Value.UserId).Metadata()) + } + + for _, md := range toDelete { + fmt.Printf("tearing down %s %s\n", md.Type(), md.ID()) + + if _, err := client.Omni().State().Teardown(ctx, md); err != nil { + return err + } + } + + for _, md := range toDelete { + _, err := client.Omni().State().WatchFor(ctx, md, state.WithFinalizerEmpty()) + if err != nil { + return err + } + + err = client.Omni().State().Destroy(ctx, md) + if err != nil { + return err + } + + fmt.Printf("destroy %s %s\n", md.Type(), md.ID()) + } + + return nil + } +} + +func init() { + userCmd.AddCommand(deleteCmd) +} diff --git a/client/pkg/omnictl/user/list.go b/client/pkg/omnictl/user/list.go new file mode 100644 index 00000000..5d87d3e7 --- /dev/null +++ b/client/pkg/omnictl/user/list.go @@ -0,0 +1,111 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package user + +import ( + "context" + "fmt" + "os" + "slices" + "strings" + "text/tabwriter" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/safe" + "github.com/cosi-project/runtime/pkg/state" + "github.com/spf13/cobra" + + "github.com/siderolabs/omni/client/pkg/client" + "github.com/siderolabs/omni/client/pkg/omni/resources/auth" + "github.com/siderolabs/omni/client/pkg/omnictl/internal/access" +) + +// listCmd represents the user list command. +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all users.", + Long: `List all existing users on the Omni instance.`, + Example: "", + Args: cobra.ExactArgs(0), + RunE: func(*cobra.Command, []string) error { + return access.WithClient(listUsers) + }, +} + +func listUsers(ctx context.Context, client *client.Client) error { + identities, err := safe.ReaderListAll[*auth.Identity](ctx, client.Omni().State(), state.WithLabelQuery( + resource.LabelExists(auth.LabelIdentityTypeServiceAccount, resource.NotMatches), + )) + if err != nil { + return err + } + + type user struct { + id string + email string + role string + labels string + } + + users, err := safe.ReaderListAll[*auth.User](ctx, client.Omni().State()) + if err != nil { + return err + } + + userList := safe.ToSlice(identities, func(identity *auth.Identity) user { + res := user{ + id: identity.TypedSpec().Value.UserId, + email: identity.Metadata().ID(), + } + + u, found := users.Find(func(user *auth.User) bool { + return user.Metadata().ID() == identity.TypedSpec().Value.UserId + }) + if !found { + return res + } + + res.role = u.TypedSpec().Value.Role + + allLabels := identity.Metadata().Labels().Raw() + + samlLabels := make([]string, 0, len(allLabels)) + + for key, value := range allLabels { + if !strings.HasPrefix(key, auth.SAMLLabelPrefix) { + continue + } + + samlLabels = append(samlLabels, strings.TrimPrefix(key, auth.SAMLLabelPrefix)+"="+value) + } + + slices.Sort(samlLabels) + + res.labels = strings.Join(samlLabels, ", ") + + return res + }) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + defer w.Flush() //nolint:errcheck + + _, err = fmt.Fprintln(w, "ID\tEMAIL\tROLE\tLABELS") + if err != nil { + return err + } + + for _, user := range userList { + _, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", user.id, user.email, user.role, user.labels) + if err != nil { + return err + } + } + + return nil +} + +func init() { + userCmd.AddCommand(listCmd) +} diff --git a/client/pkg/omnictl/user/set_role.go b/client/pkg/omnictl/user/set_role.go new file mode 100644 index 00000000..334eea49 --- /dev/null +++ b/client/pkg/omnictl/user/set_role.go @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package user + +import ( + "context" + + "github.com/cosi-project/runtime/pkg/safe" + "github.com/spf13/cobra" + + "github.com/siderolabs/omni/client/pkg/client" + "github.com/siderolabs/omni/client/pkg/omni/resources" + "github.com/siderolabs/omni/client/pkg/omni/resources/auth" + "github.com/siderolabs/omni/client/pkg/omnictl/internal/access" +) + +var setRoleCmdFlags struct { + role string +} + +// setRoleCmd represents the user role set command. +var setRoleCmd = &cobra.Command{ + Use: "set-role [email]", + Short: "Update the role of the user.", + Long: `Update the user role.`, + Example: "", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return access.WithClient(setUserRole(args[0])) + }, +} + +func setUserRole(email string) func(ctx context.Context, client *client.Client) error { + return func(ctx context.Context, client *client.Client) error { + identity, err := safe.ReaderGetByID[*auth.Identity](ctx, client.Omni().State(), email) + if err != nil { + return err + } + + _, err = safe.StateUpdateWithConflicts(ctx, client.Omni().State(), + auth.NewUser(resources.DefaultNamespace, identity.TypedSpec().Value.UserId).Metadata(), + func(user *auth.User) error { + user.TypedSpec().Value.Role = setRoleCmdFlags.role + + return nil + }, + ) + if err != nil { + return err + } + + return nil + } +} + +func init() { + setRoleCmd.PersistentFlags().StringVarP(&setRoleCmdFlags.role, "role", "r", "", "Role to use") + setRoleCmd.MarkPersistentFlagRequired("role") //nolint:errcheck + + userCmd.AddCommand(setRoleCmd) +} diff --git a/client/pkg/omnictl/user/user.go b/client/pkg/omnictl/user/user.go new file mode 100644 index 00000000..f29411f0 --- /dev/null +++ b/client/pkg/omnictl/user/user.go @@ -0,0 +1,24 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package user contains commands related to user operations. +package user + +import ( + "github.com/spf13/cobra" +) + +// userCmd represents the cluster sub-command. +var userCmd = &cobra.Command{ + Use: "user", + Aliases: []string{"u"}, + Short: "User-related subcommands.", + Long: `Commands to manage users.`, + Example: "", +} + +// RootCmd exposes root cluster command. +func RootCmd() *cobra.Command { + return userCmd +} diff --git a/cmd/integration-test/pkg/tests/cli.go b/cmd/integration-test/pkg/tests/cli.go index 13d8dd91..75309888 100644 --- a/cmd/integration-test/pkg/tests/cli.go +++ b/cmd/integration-test/pkg/tests/cli.go @@ -139,3 +139,29 @@ func createServiceAccount(ctx context.Context, t *testing.T, client *client.Clie return encodedKey } + +// AssertUserCLI verifies user management cli commands. +func AssertUserCLI(testCtx context.Context, client *client.Client, omnictlPath, httpEndpoint string) TestFunc { + return func(t *testing.T) { + name := "test-" + uuid.NewString() + + key := createServiceAccount(testCtx, t, client, name) + + _, _, err := runCmd(omnictlPath, httpEndpoint, key, "user", "create", "a@a.com", "--role", "Admin") + require.NoError(t, err) + + stdin, _, err := runCmd(omnictlPath, httpEndpoint, key, "user", "list") + + require.Contains(t, stdin.String(), "a@a.com") + + _, _, err = runCmd(omnictlPath, httpEndpoint, key, "user", "set-role", "--role", "Reader", "a@a.com") + require.NoError(t, err) + + _, _, err = runCmd(omnictlPath, httpEndpoint, key, "user", "delete", "a@a.com") + require.NoError(t, err) + + stdin, _, err = runCmd(omnictlPath, httpEndpoint, key, "user", "list") + + require.NotContains(t, stdin.String(), "a@a.com") + } +} diff --git a/cmd/integration-test/pkg/tests/tests.go b/cmd/integration-test/pkg/tests/tests.go index 32d7a836..db6ced61 100644 --- a/cmd/integration-test/pkg/tests/tests.go +++ b/cmd/integration-test/pkg/tests/tests.go @@ -196,6 +196,18 @@ Generate various Talos images with Omni and try to download them.`, }, }, }, + { + Name: "CLICommands", + Description: ` +Verify various omnictl commands.`, + Parallel: true, + Subtests: []subTest{ + { + "OmnictlUserCLIShouldWork", + AssertUserCLI(ctx, rootClient, options.OmnictlPath, options.HTTPEndpoint), + }, + }, + }, { Name: "KubernetesNodeAudit", Description: "Test the auditing of the Kubernetes nodes, i.e. when a node is gone from the Omni perspective but still exists on the Kubernetes cluster.", diff --git a/frontend/src/api/resources.ts b/frontend/src/api/resources.ts index 71cda1b4..8df3a8d3 100644 --- a/frontend/src/api/resources.ts +++ b/frontend/src/api/resources.ts @@ -29,6 +29,8 @@ export const ServicePortAnnotationKey = "omni-kube-service-exposer.sidero.dev/po export const ServiceIconAnnotationKey = "omni-kube-service-exposer.sidero.dev/icon"; export const installDiskMinSize = 5e+09; export const authPublicKeyIDQueryParam = "public-key-id"; +export const ServiceAccountDomain = "serviceaccount.omni.sidero.dev"; +export const InfraProviderServiceAccountDomain = "infra-provider.serviceaccount.omni.sidero.dev"; export const SecureBoot = "secureboot"; export const DefaultTalosVersion = "1.7.6"; export const PatchWeightInstallDisk = 0; @@ -177,6 +179,7 @@ export const AuthConfigID = "auth-config"; export const AuthConfigType = "AuthConfigs.omni.sidero.dev"; export const IdentityType = "Identities.omni.sidero.dev"; export const SAMLLabelPrefix = "saml.omni.sidero.dev/"; +export const LabelPublicKeyUserID = "user-id"; export const LabelIdentityUserID = "user-id"; export const LabelIdentityTypeServiceAccount = "type-service-account"; export const PublicKeyType = "PublicKeys.omni.sidero.dev"; diff --git a/frontend/src/components/common/Button/TButton.vue b/frontend/src/components/common/Button/TButton.vue index 837f22b3..d63a8a41 100644 --- a/frontend/src/components/common/Button/TButton.vue +++ b/frontend/src/components/common/Button/TButton.vue @@ -181,7 +181,7 @@ const textOrder = computed(() : StyleValue => { } .t-button.compact { - @apply bg-transparent + @apply bg-naturals-N4 py-0.5 px-2 h-6 diff --git a/frontend/src/components/common/List/TList.vue b/frontend/src/components/common/List/TList.vue index 248cf17b..c28dc570 100644 --- a/frontend/src/components/common/List/TList.vue +++ b/frontend/src/components/common/List/TList.vue @@ -99,6 +99,8 @@ defineExpose({ const dots = "..."; +const emit = defineEmits(["itemsUpdate"]); + const props = defineProps<{ pagination?: boolean, search?: boolean, @@ -138,6 +140,10 @@ const offset = computed(() => { return (currentPage.value - 1) * selectedItemsPerPage.value; }); +vueWatch(items.value, () => { + emit('itemsUpdate', items.value); +}); + const sortByState = computed(() => { if (!props.sortOptions) { return {}; diff --git a/frontend/src/methods/user.ts b/frontend/src/methods/user.ts index 198a8216..8539f238 100644 --- a/frontend/src/methods/user.ts +++ b/frontend/src/methods/user.ts @@ -8,7 +8,16 @@ import { UserType, IdentityType, LabelIdentityUserID, + RoleInfraProvider, + ServiceAccountDomain, + InfraProviderServiceAccountDomain, } from "@/api/resources"; + +import { + enums, + generateKey, +} from 'openpgp/lightweight'; + import { Resource, ResourceService } from "@/api/grpc"; import { UserSpec } from "@/api/omni/specs/auth.pb"; import { v4 as uuidv4 } from 'uuid'; @@ -16,6 +25,7 @@ import { IdentitySpec } from "@/api/omni/specs/auth.pb"; import { Runtime } from "@/api/common/omni.pb"; import { Code } from "@/api/google/rpc/code.pb"; import { withRuntime } from "@/api/options"; +import { ManagementService } from "@/api/omni/management/management.pb"; export const createUser = async (email: string, role: string) => { const user: Resource = { @@ -79,3 +89,65 @@ export const updateRole = async (userID: string, role: string) => { await ResourceService.Update(user, undefined, withRuntime(Runtime.Omni)); }; +export const createServiceAccount = async (name: string, role: string, expirationDays: number = 365) => { + const email = `${name}@${ role === RoleInfraProvider ? InfraProviderServiceAccountDomain : ServiceAccountDomain }`; + + const { privateKey, publicKey } = await generateKey({ + type: 'ecc', + curve: 'ed25519', + userIDs: [{ email: email }, ], + keyExpirationTime: expirationDays * 24 * 60 * 60, + config: { + preferredCompressionAlgorithm: enums.compression.zlib, + preferredSymmetricAlgorithm: enums.symmetric.aes256, + preferredHashAlgorithm: enums.hash.sha256, + } + }); + + await ManagementService.CreateServiceAccount({ + armored_pgp_public_key: publicKey, + role, + name: role === RoleInfraProvider ? `infra-provider:${ name }` : name, + }); + + const saKey = { + name: name, + pgp_key: privateKey.trim(), + }; + + const raw = JSON.stringify(saKey); + + return btoa(raw) +}; + +export const renewServiceAccount = async (id: string, expirationDays: number = 365) => { + const { privateKey, publicKey } = await generateKey({ + type: 'ecc', + curve: 'ed25519', + userIDs: [{ email: id }, ], + keyExpirationTime: expirationDays * 24 * 60 * 60, + config: { + preferredCompressionAlgorithm: enums.compression.zlib, + preferredSymmetricAlgorithm: enums.symmetric.aes256, + preferredHashAlgorithm: enums.hash.sha256, + } + }); + + const parts = id.split("@"); + const name = parts[1] === InfraProviderServiceAccountDomain ? `infra-provider:${parts[0]}` : parts[0]; + + await ManagementService.RenewServiceAccount({ + armored_pgp_public_key: publicKey, + name: name + }); + + const saKey = { + name: name, + pgp_key: privateKey.trim(), + }; + + const raw = JSON.stringify(saKey); + + return btoa(raw) +}; + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c68e4651..28dd18cd 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -15,6 +15,7 @@ import OmniMachineLogs from "@/views/omni/Machines/MachineLogs.vue"; import OmniMachinePatches from "@/views/omni/Machines/MachinePatches.vue"; import OmniMachine from "@/views/omni/Machines/Machine.vue"; import OmniUsers from "@/views/omni/Users/Users.vue"; +import OmniServiceAccounts from "@/views/omni/Users/ServiceAccounts.vue"; import OmniSettings from "@/views/omni/Settings/Settings.vue"; import Authenticate from "@/views/omni/Auth/Authenticate.vue"; import OmniMachineClasses from "@/views/omni/MachineClasses/MachineClasses.vue"; @@ -68,7 +69,9 @@ import UserDestroy from "@/views/omni/Modals/UserDestroy.vue"; import UpdateKubernetes from "@/views/omni/Modals/UpdateKubernetes.vue"; import UpdateTalos from "@/views/omni/Modals/UpdateTalos.vue"; import UserCreate from "@/views/omni/Modals/UserCreate.vue"; -import UserEdit from "@/views/omni/Modals/UserEdit.vue"; +import RoleEdit from "@/views/omni/Modals/RoleEdit.vue"; +import ServiceAccountCreate from "@/views/omni/Modals/ServiceAccountCreate.vue"; +import ServiceAccountRenew from "@/views/omni/Modals/ServiceAccountRenew.vue"; import { current } from "@/context"; import { authGuard } from "@auth0/auth0-vue"; @@ -242,6 +245,13 @@ const routes: RouteRecordRaw[] = [ inner: OmniUsers, } }, + { + path: "serviceaccounts", + name: "ServiceAccounts", + components: { + inner: OmniServiceAccounts, + } + }, { path: "backups", name: "BackupStorage", @@ -445,7 +455,9 @@ const modals = { configPatchDestroy: ConfigPatchDestroy, userDestroy: UserDestroy, userCreate: UserCreate, - userEdit: UserEdit, + serviceAccountCreate: ServiceAccountCreate, + serviceAccountRenew: ServiceAccountRenew, + roleEdit: RoleEdit, updateExtensions: UpdateExtensions, }; diff --git a/frontend/src/views/omni/Modals/UserEdit.vue b/frontend/src/views/omni/Modals/RoleEdit.vue similarity index 83% rename from frontend/src/views/omni/Modals/UserEdit.vue rename to frontend/src/views/omni/Modals/RoleEdit.vue index 45116f20..c10e25aa 100644 --- a/frontend/src/views/omni/Modals/UserEdit.vue +++ b/frontend/src/views/omni/Modals/RoleEdit.vue @@ -8,12 +8,12 @@ included in the LICENSE file.