From c7d563a714ee3a3b241b4509d28218d66dcb4737 Mon Sep 17 00:00:00 2001 From: olamilekan000 Date: Wed, 21 Oct 2026 18:35:36 +0100 Subject: [PATCH] Move platform metadata off *.kcp.io, add platform admins and fix rough edges - Rename tenancy.platform.kcp.io/owner-uid to tenancy.kcp.hub/owner-uid and marketplace.platform.kcp.io/* to marketplace.kcp.hub/*, since kcp's ReservedMetadata admission rejects *.kcp.io keys from non-system callers - Update example and quickstart APIExports and READMEs for the new keys - tenancy and marketplace init update their RBAC in place, so restarts no longer fail the reserved-metadata check on internal.kcp.io annotations - tenancy and marketplace init grant a narrow platform-admin role (--platform-admin-user/--platform-admin-group); Tilt adds the admin@platform.example.com dex user as platform admin - contribctl offers and enable fall back to the token cached by login - Tilt operators wait for ingress-forward before starting - Tilt seeds with contribctl admin seed - Console Admin page registers Producers, shown only to those allowed - API Explorer skips listing kinds that cannot be listed - Catalog explains how to publish an API when it is empty - Return the server error on shutdown instead of dropping it (errcheck) - Remove committed vitest cache and tsbuildinfo, and ignore them - make test-e2e includes marketplace Signed-off-by: olamilekan000 --- .gitignore | 9 +- Makefile | 2 +- Tiltfile | 24 ++-- access/pkg/server/server.go | 3 +- cmd/cli/main.go | 7 +- hack/tilt/README.md | 15 ++- hack/tilt/dex.yaml | 9 +- marketplace/README.md | 19 ++- marketplace/cmd/init.go | 13 +- marketplace/config/examples/README.md | 6 +- marketplace/config/examples/things.yaml | 8 +- marketplace/config/examples/widgets.yaml | 6 +- marketplace/pkg/bootstrap/bootstrap.go | 113 ++++++++++++------ marketplace/pkg/cli/offers.go | 9 ++ marketplace/pkg/operator/workspaces.go | 2 +- marketplace/pkg/server/server.go | 3 +- .../sdk/apis/marketplace/v1alpha1/types.go | 13 +- marketplace/test/e2e/fixture_test.go | 2 +- mcp/README.md | 2 +- mcp/config/apiexport/apiexport-mcp.yaml | 8 +- .../results.json | 1 - producer-quickstart/config/apiexport.yaml | 10 +- tenancy/README.md | 33 +++-- tenancy/cmd/init.go | 13 +- tenancy/pkg/bootstrap/bootstrap.go | 77 ++++++------ tenancy/pkg/cli/login.go | 7 ++ tenancy/pkg/operator/workspaces.go | 4 +- tenancy/pkg/server/server.go | 3 +- ui/README.md | 6 +- ui/src/pages/AdminPage.vue | 94 ++++++++++++++- ui/src/pages/ApiViewerPage.vue | 10 +- ui/src/pages/CatalogPage.vue | 29 ++++- ui/tsconfig.tsbuildinfo | 1 - 33 files changed, 395 insertions(+), 166 deletions(-) delete mode 100644 node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json delete mode 100644 ui/tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index 5578588..da8823f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ pki/ # Kubeconfigs the README walkthrough writes (`tenancyctl kubeconfig -o ...`). *.kubeconfig +# Serial files `openssl x509 -CAcreateserial` leaves beside wherever it ran. +*.srl + # Binaries built into component roots by `go build ./cmd/...`. They shadow # bin/ on tab-completion and go stale silently; use `make build`. /ephemeral/ephemeral-virtual-workspace @@ -29,6 +32,8 @@ pki/ apiserver.local.config/ /marketplace/bin/ -# The web UI's build output and dependencies. -ui/node_modules/ +# The web UI's build output and dependencies. Running vitest from the repo +# root also leaves a node_modules/ cache there. +node_modules/ ui/dist/ +*.tsbuildinfo diff --git a/Makefile b/Makefile index 4be9a55..7694300 100644 --- a/Makefile +++ b/Makefile @@ -99,7 +99,7 @@ verify-fork-pin: ## Check the kcp Kubernetes fork pin matches virtual-workspace- # Each component owns its e2e harness; these are the entry points CI uses. .PHONY: test-e2e -test-e2e: test-e2e-access test-e2e-mcp test-e2e-ephemeral test-e2e-tenancy ## Run every component's e2e tests in sequence +test-e2e: test-e2e-access test-e2e-mcp test-e2e-ephemeral test-e2e-tenancy test-e2e-marketplace ## Run every component's e2e tests in sequence .PHONY: test-e2e-access test-e2e-access: ## Run the access VW e2e tests (throwaway kind cluster via kcp-operator) diff --git a/Tiltfile b/Tiltfile index 9c2fbb9..9760e20 100644 --- a/Tiltfile +++ b/Tiltfile @@ -271,8 +271,8 @@ if DEX_ENABLED: if str(local('grep -qs dex.kcp.localhost /etc/hosts && echo yes || echo no', quiet=True)).strip() == 'no': print('WARNING: dex.kcp.localhost missing from /etc/hosts — add it to the 127.0.0.1 line for login to work') - print('dex → %s (alice@team-a.example.com / bob@team-b.example.com / mallory@strangers.example.com, password "password")' % DEX_ISSUER) - print('login: bin/contribctl tenancy login --issuer %s --certificate-authority %s/dex/ca.crt' % (DEX_ISSUER, TILT_DIR)) + print('dex → %s (alice@team-a.example.com / bob@team-b.example.com / mallory@strangers.example.com / admin@platform.example.com, password "password")' % DEX_ISSUER) + print('login: bin/contribctl login --issuer %s --certificate-authority %s/dex/ca.crt' % (DEX_ISSUER, TILT_DIR)) print('walkthrough: hack/tilt/README.md') print('JWT usernames arrive as the plain email, e.g. alice@team-a.example.com') @@ -524,8 +524,10 @@ def component(name, binary, operator_args): serve_cmd='%s operator --kubeconfig %s %s --v=4' % (bin_path, KUBECONFIG, operator_args), deps=[bin_path], # The exports it reconciles are installed by the virtual - # workspace's init container. - resource_deps=['%s-build' % name, '%s-vw' % name, 'contrib-kubeconfig'], + # workspace's init container. ingress-forward is only ready once + # kcp answers on the host, and the operator exits if it cannot + # reach kcp at startup. + resource_deps=['%s-build' % name, '%s-vw' % name, 'contrib-kubeconfig', 'ingress-forward'], labels=[name], ) @@ -593,9 +595,15 @@ if enabled('MCP'): print('mcp VW → %s/services/mcp' % FRONT_PROXY_URL) print(' publish it to the catalog: tilt trigger mcp-offering') +# The dex user who runs the platform: init binds it to the platform-admin +# role in the tenancy store and the marketplace catalog, which is what +# makes the console's Admin page appear for it. +PLATFORM_ADMIN = 'admin@platform.example.com' +PLATFORM_ADMIN_INIT_ARGS = ' - --platform-admin-user=%s\n' % PLATFORM_ADMIN + if enabled('TENANCY'): virtual_workspace('tenancy', 'tenancy-vw', 'tenancy', - init_args='', + init_args=PLATFORM_ADMIN_INIT_ARGS, # No --workspace-path: the server kubeconfig is already scoped to # root:tenancy:controllers by targetWorkspace, and retargeting it # would append a second /clusters/ segment. @@ -611,14 +619,14 @@ if enabled('TENANCY'): '--tenants-workspace root:tenancy:tenants', ) # Demo data, so a fresh environment answers whoami with something: - # the component's own `tenancy seed` command converges on tenant acme + # the component's own `admin seed` command converges on tenant acme # + project web, alice admin / bob view — each granted under both # identities (dex email and dev-cert CN). Idempotent; re-run anytime # with `tilt trigger tenancy-seed`. SEED_ENABLED=false skips it. if os.getenv('SEED_ENABLED', 'true').lower() not in ('false', '0', 'no', 'off'): local_resource('tenancy-seed', cmd='make --no-print-directory build-cli && ' + - 'bin/contribctl tenancy seed --kubeconfig ' + KUBECONFIG + + 'bin/contribctl admin seed --kubeconfig ' + KUBECONFIG + ' --tenant acme --display-name "Acme Corp" --project web' + ' --admin alice@team-a.example.com --admin alice' + ' --view bob@team-b.example.com --view bob', @@ -629,7 +637,7 @@ if enabled('TENANCY'): if enabled('MARKETPLACE'): virtual_workspace('marketplace', 'marketplace-vw', 'marketplace', - init_args='', + init_args=PLATFORM_ADMIN_INIT_ARGS, vw_args=''.join([' - %s\n' % a for a in OIDC_VW_ARGS_LIST]), ) component('marketplace', 'marketplace-vw', diff --git a/access/pkg/server/server.go b/access/pkg/server/server.go index 018468a..7923b77 100644 --- a/access/pkg/server/server.go +++ b/access/pkg/server/server.go @@ -176,8 +176,7 @@ func Run(ctx context.Context, o *Options) error { if err != nil && !errors.Is(err, context.Canceled) { return fmt.Errorf("rbac provider failed: %w", err) } - <-serveErr - return nil + return <-serveErr case err := <-serveErr: return err } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 4243846..a64a364 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -124,15 +124,16 @@ kubeconfig and are for whoever runs the platform.`, } // marketplaceDefaults is the other piece of glue: the server `login --url` -// stored, and how to verify it, handed to the marketplace commands as -// their flag defaults. The marketplace never reads the context file; it -// only knows it was told an address. +// stored, how to verify it, and the token `login` cached, handed to the +// marketplace commands as their flag defaults. The marketplace never reads +// the context file or the token cache; it only knows what it was told. func marketplaceDefaults() marketplacecli.ServerDefaults { stored := clicontext.Load() return marketplacecli.ServerDefaults{ URL: stored.ServiceURL("marketplace"), CertificateAuthority: stored.CertificateAuthority, Insecure: stored.Insecure, + Token: tenancycli.CachedToken(), } } diff --git a/hack/tilt/README.md b/hack/tilt/README.md index 414ea05..9b84d74 100644 --- a/hack/tilt/README.md +++ b/hack/tilt/README.md @@ -206,7 +206,7 @@ fills it. ## 2. Log in with Dex Dex is the one identity provider kcp and both virtual workspaces trust, so -a token names the same user everywhere. Three users, all with the password +a token names the same user everywhere. Four users, all with the password `password`: | Login | Identity everywhere | @@ -214,6 +214,13 @@ a token names the same user everywhere. Three users, all with the password | `alice@team-a.example.com` | `alice@team-a.example.com` | | `bob@team-b.example.com` | `bob@team-b.example.com` | | `mallory@strangers.example.com` | `mallory@strangers.example.com` | +| `admin@platform.example.com` | `admin@platform.example.com` | + +`admin` is the platform administrator: the tenancy and marketplace init +steps run with `--platform-admin-user=admin@platform.example.com`, which +binds it to a role that may manage Tenants in `root:tenancy:store` and +Producers in `root:marketplace:catalog`, and nothing else. It is the only +user the console shows the Admin page to. The identity is the **plain email, with no groups**: the kcp-operator only speaks the legacy `--oidc-*` claim mapping, so the CEL claim mapping used @@ -461,9 +468,9 @@ metadata: # The offering is the export: these annotations publish it, and its own # permissionClaims (none here) are the consent form. annotations: - marketplace.platform.kcp.io/publish: "true" - marketplace.platform.kcp.io/display-name: "Things as a service" - marketplace.platform.kcp.io/description: "One Thing per workspace, batteries included." + marketplace.kcp.hub/publish: "true" + marketplace.kcp.hub/display-name: "Things as a service" + marketplace.kcp.hub/description: "One Thing per workspace, batteries included." spec: resources: - name: things diff --git a/hack/tilt/dex.yaml b/hack/tilt/dex.yaml index 40fe197..f381e47 100644 --- a/hack/tilt/dex.yaml +++ b/hack/tilt/dex.yaml @@ -4,8 +4,9 @@ # https://dex.kcp.localhost:8443/dex both from the host (the ingress # port-forward) and from the kcp pods (hostAliases -> the envoy gateway). # -# The identities mirror hack/dex/dex.sh: alice/bob/mallory, one shared -# password ("password"), a public client "kcp" for PKCE and the password +# The identities mirror hack/dex/dex.sh: alice/bob/mallory, plus admin, +# the platform administrator the Tiltfile grants the platform-admin role +# to. One shared password ("password"), a public client "kcp" for PKCE and the password # grant. With kcp's operator-side claim mapping (usernameClaim: email, no # prefix) the resulting kcp username is the plain email, # e.g. alice@team-a.example.com. @@ -116,6 +117,10 @@ data: hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: mallory userID: "3" + - email: admin@platform.example.com + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: admin + userID: "4" --- apiVersion: apps/v1 kind: Deployment diff --git a/marketplace/README.md b/marketplace/README.md index 84d7728..6a7ae7e 100644 --- a/marketplace/README.md +++ b/marketplace/README.md @@ -149,6 +149,13 @@ bin/marketplace-vw init --kubeconfig "$KUBECONFIG" This creates the three workspaces from [the model](#the-model) and nothing below them. Idempotent: run it twice and the second run is a no-op. +`--platform-admin-user` and `--platform-admin-group` (both repeatable) name +who may register Producers: they are bound to +`marketplace.platform.kcp.io:platform-admin` in the catalog, a role that +may enter it and manage Producers and nothing else. The binding holds +exactly the identities of the latest run, so dropping one from the flags +revokes it. + ### 5. Run the operator ```sh @@ -238,9 +245,9 @@ metadata: name: things.acme.example # These three annotations are the offering. There is no other object. annotations: - marketplace.platform.kcp.io/publish: "true" - marketplace.platform.kcp.io/display-name: "Things as a service" - marketplace.platform.kcp.io/description: "One Thing per workspace, batteries included." + marketplace.kcp.hub/publish: "true" + marketplace.kcp.hub/display-name: "Things as a service" + marketplace.kcp.hub/description: "One Thing per workspace, batteries included." spec: resources: - name: things @@ -273,7 +280,7 @@ with it, and the export and everyone already bound to it are untouched: ```sh kubectl --server "$PRODUCER" annotate apiexport things.acme.example \ - marketplace.platform.kcp.io/publish- + marketplace.kcp.hub/publish- ``` ### 8. Create a consumer workspace and enable @@ -467,8 +474,8 @@ the APIExport: ```yaml metadata: annotations: - marketplace.platform.kcp.io/instructions: things-instructions # a ConfigMap, same workspace - marketplace.platform.kcp.io/docs-url: https://docs.example/things + marketplace.kcp.hub/instructions: things-instructions # a ConfigMap, same workspace + marketplace.kcp.hub/docs-url: https://docs.example/things ``` The ConfigMap's keys are sections, shown as tabs; a leading `NN-` orders diff --git a/marketplace/cmd/init.go b/marketplace/cmd/init.go index 9dc7120..fce5d7a 100644 --- a/marketplace/cmd/init.go +++ b/marketplace/cmd/init.go @@ -41,6 +41,8 @@ func newInitCommand() *cobra.Command { workspaceType string serverUsers []string serverGroups []string + adminUsers []string + adminGroups []string timeout time.Duration ) @@ -109,7 +111,11 @@ created. Idempotent: safe to run on every pod start and every upgrade.`, if err := bootstrap.BindCatalog(ctx, catalogCfg, result); err != nil { return fmt.Errorf("bind the Provider API into %s: %w", catalogPath, err) } - logger.Info("catalog ready", "workspace", catalogPath) + if err := bootstrap.GrantPlatformAdmins(ctx, catalogCfg, adminUsers, adminGroups); err != nil { + return fmt.Errorf("grant platform admins in %s: %w", catalogPath, err) + } + logger.Info("catalog ready", "workspace", catalogPath, + "platformAdminUsers", adminUsers, "platformAdminGroups", adminGroups) } if producersWorkspace != "" { producersPath, err = accessbootstrap.JoinWorkspacePath(workspacePrefix, producersWorkspace) @@ -183,6 +189,11 @@ created. Idempotent: safe to run on every pod start and every upgrade.`, "or the virtual workspace does not run as this bootstrap's own credential.") cmd.Flags().StringSliceVar(&serverGroups, "server-group", nil, "Additional Group to grant the server role to, repeatable.") + cmd.Flags().StringSliceVar(&adminUsers, "platform-admin-user", nil, + "User who may manage Producers in the catalog workspace, repeatable. Bound to a role "+ + "that allows exactly that; anyone left off the list on a later run loses it.") + cmd.Flags().StringSliceVar(&adminGroups, "platform-admin-group", nil, + "Group, as the identity provider asserts it, whose members may manage Producers, repeatable.") cmd.Flags().DurationVar(&timeout, "timeout", 3*time.Minute, "Overall time budget for installing and verifying.") diff --git a/marketplace/config/examples/README.md b/marketplace/config/examples/README.md index 0d24713..192c9c2 100644 --- a/marketplace/config/examples/README.md +++ b/marketplace/config/examples/README.md @@ -22,9 +22,9 @@ such kind. The annotations on the `APIExport` are the offering: ```yaml metadata: annotations: - marketplace.platform.kcp.io/publish: "true" - marketplace.platform.kcp.io/display-name: Things as a service - marketplace.platform.kcp.io/description: One Thing per workspace, batteries included. + marketplace.kcp.hub/publish: "true" + marketplace.kcp.hub/display-name: Things as a service + marketplace.kcp.hub/description: One Thing per workspace, batteries included. ``` Only the exact string `"true"` publishes; anything else leaves the export diff --git a/marketplace/config/examples/things.yaml b/marketplace/config/examples/things.yaml index 941c5c7..34c911e 100644 --- a/marketplace/config/examples/things.yaml +++ b/marketplace/config/examples/things.yaml @@ -50,10 +50,10 @@ metadata: # Only the exact string "true" publishes. Anything else leaves the # export off the catalog, which is the safe direction: being on it is # what grants `bind` to strangers. - marketplace.platform.kcp.io/publish: "true" - marketplace.platform.kcp.io/display-name: Things as a service - marketplace.platform.kcp.io/instructions: things-instructions - marketplace.platform.kcp.io/description: One Thing per workspace, batteries included. + marketplace.kcp.hub/publish: "true" + marketplace.kcp.hub/display-name: Things as a service + marketplace.kcp.hub/instructions: things-instructions + marketplace.kcp.hub/description: One Thing per workspace, batteries included. spec: resources: - name: things diff --git a/marketplace/config/examples/widgets.yaml b/marketplace/config/examples/widgets.yaml index 2bcc211..7ddc352 100644 --- a/marketplace/config/examples/widgets.yaml +++ b/marketplace/config/examples/widgets.yaml @@ -49,9 +49,9 @@ kind: APIExport metadata: name: widgets.acme.example annotations: - marketplace.platform.kcp.io/publish: "true" - marketplace.platform.kcp.io/display-name: Widgets - marketplace.platform.kcp.io/description: A widget kind, and nothing else. + marketplace.kcp.hub/publish: "true" + marketplace.kcp.hub/display-name: Widgets + marketplace.kcp.hub/description: A widget kind, and nothing else. spec: resources: - name: widgets diff --git a/marketplace/pkg/bootstrap/bootstrap.go b/marketplace/pkg/bootstrap/bootstrap.go index 08c8e1b..691ee77 100644 --- a/marketplace/pkg/bootstrap/bootstrap.go +++ b/marketplace/pkg/bootstrap/bootstrap.go @@ -372,74 +372,113 @@ func applyManifests(ctx context.Context, dyn dynamic.Interface, mapper *restmapp } } -// applyServerIdentity grants the server role to the given identities: what -// the operator and the virtual workspace need in the exports workspace to -// use the exports' virtual workspaces when they do not run as the -// bootstrap credential. The nonResourceURL access rule is load-bearing — -// RBAC in a workspace means nothing to a subject that cannot enter it. -func applyServerIdentity(ctx context.Context, kube kubernetes.Interface, users, groups []string) error { +// GrantPlatformAdmins makes the given identities administrators of the +// Producer registry cfg points at: they may enter it and manage Producers, +// and nothing else. It is applied even with no identities, so that +// dropping someone from the list revokes them. +func GrantPlatformAdmins(ctx context.Context, cfg *rest.Config, users, groups []string) error { + kube, err := kubernetes.NewForConfig(cfg) + if err != nil { + return fmt.Errorf("build kubernetes client: %w", err) + } role := &rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{Name: marketplacev1alpha1.GroupName + ":server"}, + ObjectMeta: metav1.ObjectMeta{Name: marketplacev1alpha1.GroupName + ":platform-admin"}, Rules: []rbacv1.PolicyRule{ { Verbs: []string{"access"}, NonResourceURLs: []string{"/"}, }, { - APIGroups: []string{"apis.kcp.io"}, - Resources: []string{"apiexports", "apiexportendpointslices", "apibindings"}, - Verbs: []string{"get", "list", "watch"}, - }, - { - APIGroups: []string{"apis.kcp.io"}, - Resources: []string{"apiexports/content"}, - Verbs: []string{"*"}, + APIGroups: []string{marketplacev1alpha1.GroupName}, + Resources: []string{"producers"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, }, }, } + return applyRoleAndBinding(ctx, kube, role, users, groups) +} + +// applyRoleAndBinding creates or updates a ClusterRole and a binding of the +// same name holding exactly the given subjects. Updates change only rules, +// subjects and roleRef on the stored object: kcp annotates these objects +// itself, and writing back a copy without its internal.kcp.io annotations +// is rejected as modifying reserved metadata. +func applyRoleAndBinding(ctx context.Context, kube kubernetes.Interface, role *rbacv1.ClusterRole, users, groups []string) error { if _, err := kube.RbacV1().ClusterRoles().Create(ctx, role, metav1.CreateOptions{}); err != nil { if !apierrors.IsAlreadyExists(err) { return err } - if _, err := kube.RbacV1().ClusterRoles().Update(ctx, role, metav1.UpdateOptions{}); err != nil { + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + existing, err := kube.RbacV1().ClusterRoles().Get(ctx, role.Name, metav1.GetOptions{}) + if err != nil { + return err + } + existing.Rules = role.Rules + _, err = kube.RbacV1().ClusterRoles().Update(ctx, existing, metav1.UpdateOptions{}) + return err + }); err != nil { return err } } - binding := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: marketplacev1alpha1.GroupName + ":server"}, - RoleRef: rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "ClusterRole", - Name: role.Name, - }, - } + subjects := make([]rbacv1.Subject, 0, len(users)+len(groups)) for _, u := range users { - binding.Subjects = append(binding.Subjects, rbacv1.Subject{ - APIGroup: rbacv1.GroupName, Kind: "User", Name: u, - }) + subjects = append(subjects, rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: u}) } for _, g := range groups { - binding.Subjects = append(binding.Subjects, rbacv1.Subject{ - APIGroup: rbacv1.GroupName, Kind: "Group", Name: g, - }) + subjects = append(subjects, rbacv1.Subject{Kind: rbacv1.GroupKind, APIGroup: rbacv1.GroupName, Name: g}) + } + binding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: role.Name}, + RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: role.Name}, + Subjects: subjects, } if _, err := kube.RbacV1().ClusterRoleBindings().Create(ctx, binding, metav1.CreateOptions{}); err != nil { if !apierrors.IsAlreadyExists(err) { return err } - existing, err := kube.RbacV1().ClusterRoleBindings().Get(ctx, binding.Name, metav1.GetOptions{}) - if err != nil { - return err - } - binding.ResourceVersion = existing.ResourceVersion - if _, err := kube.RbacV1().ClusterRoleBindings().Update(ctx, binding, metav1.UpdateOptions{}); err != nil { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + existing, err := kube.RbacV1().ClusterRoleBindings().Get(ctx, binding.Name, metav1.GetOptions{}) + if err != nil { + return err + } + existing.Subjects = subjects + existing.RoleRef = binding.RoleRef + _, err = kube.RbacV1().ClusterRoleBindings().Update(ctx, existing, metav1.UpdateOptions{}) return err - } + }) } return nil } +// applyServerIdentity grants the server role to the given identities: what +// the operator and the virtual workspace need in the exports workspace to +// use the exports' virtual workspaces when they do not run as the +// bootstrap credential. The nonResourceURL access rule is load-bearing — +// RBAC in a workspace means nothing to a subject that cannot enter it. +func applyServerIdentity(ctx context.Context, kube kubernetes.Interface, users, groups []string) error { + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: marketplacev1alpha1.GroupName + ":server"}, + Rules: []rbacv1.PolicyRule{ + { + Verbs: []string{"access"}, + NonResourceURLs: []string{"/"}, + }, + { + APIGroups: []string{"apis.kcp.io"}, + Resources: []string{"apiexports", "apiexportendpointslices", "apibindings"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"apis.kcp.io"}, + Resources: []string{"apiexports/content"}, + Verbs: []string{"*"}, + }, + }, + } + return applyRoleAndBinding(ctx, kube, role, users, groups) +} + // WaitForEndpointSliceURLs reports the virtual workspace URLs one endpoint // slice has published, nudging first. An empty answer is informational, // never fatal. diff --git a/marketplace/pkg/cli/offers.go b/marketplace/pkg/cli/offers.go index fec716f..ced66ab 100644 --- a/marketplace/pkg/cli/offers.go +++ b/marketplace/pkg/cli/offers.go @@ -74,6 +74,9 @@ type ServerDefaults struct { // skips that, for local testing. CertificateAuthority string Insecure bool + // Token is the bearer token the composer's login obtained, used when + // no credential is given explicitly. + Token string } // catalogOptions are the flags for reaching the marketplace virtual @@ -143,6 +146,9 @@ func (o *catalogOptions) client() (client.Client, error) { } cfg.BearerToken = strings.TrimSpace(string(raw)) } + if cfg.CertFile == "" && cfg.BearerToken == "" && o.kubeconfig == "" { + cfg.BearerToken = o.defaults.Token + } if o.caFile != "" { cfg.CAFile, cfg.CAData = o.caFile, nil } @@ -347,6 +353,9 @@ func bindingClient(url, kubeconfig string, o *catalogOptions) (client.Client, er } cfg.BearerToken = strings.TrimSpace(string(raw)) } + if cfg.CertFile == "" && cfg.BearerToken == "" && kubeconfig == "" { + cfg.BearerToken = o.defaults.Token + } if o.insecure { cfg.Insecure, cfg.CAFile, cfg.CAData = true, "", nil } diff --git a/marketplace/pkg/operator/workspaces.go b/marketplace/pkg/operator/workspaces.go index 83e0f16..d309546 100644 --- a/marketplace/pkg/operator/workspaces.go +++ b/marketplace/pkg/operator/workspaces.go @@ -34,7 +34,7 @@ import ( // ownerUIDLabel ties a provisioned Workspace to the Provider that asked // for it, so a reconcile recognizes its own workspace and a name collision // with someone else's is told apart from an earlier run. -const ownerUIDLabel = marketplacev1alpha1.GroupName + "/owner-uid" +const ownerUIDLabel = marketplacev1alpha1.MetadataDomain + "/owner-uid" // provisioned describes the workspace a producer ended up with. type provisioned struct { diff --git a/marketplace/pkg/server/server.go b/marketplace/pkg/server/server.go index 0759e99..eee4c06 100644 --- a/marketplace/pkg/server/server.go +++ b/marketplace/pkg/server/server.go @@ -174,8 +174,7 @@ func Run(ctx context.Context, o *Options) error { if err != nil && !errors.Is(err, context.Canceled) { return fmt.Errorf("directory producer failed: %w", err) } - <-serveErr - return nil + return <-serveErr case err := <-serveErr: return err } diff --git a/marketplace/sdk/apis/marketplace/v1alpha1/types.go b/marketplace/sdk/apis/marketplace/v1alpha1/types.go index 28ad5a0..1677f7c 100644 --- a/marketplace/sdk/apis/marketplace/v1alpha1/types.go +++ b/marketplace/sdk/apis/marketplace/v1alpha1/types.go @@ -135,23 +135,26 @@ type ProducerList struct { // own spec.permissionClaims are the claim surface, so there is nothing // left to reconcile them against. const ( + // MetadataDomain prefixes the marketplace's label and annotation keys. + MetadataDomain = "marketplace.kcp.hub" + // AnnotationPublish opts an APIExport into the catalog. Its value must // be "true"; anything else, or its absence, means not published. // // Publishing is the producer's own act, on their own object, in their // own workspace — and removing this annotation revokes the `bind` // grant, which is what takes an offering off the catalog. - AnnotationPublish = GroupName + "/publish" + AnnotationPublish = MetadataDomain + "/publish" // AnnotationDisplayName is the human name shown in the catalog. // Absent, the export's own name is used. - AnnotationDisplayName = GroupName + "/display-name" + AnnotationDisplayName = MetadataDomain + "/display-name" // AnnotationDescription is free text for humans. - AnnotationDescription = GroupName + "/description" + AnnotationDescription = MetadataDomain + "/description" // AnnotationDocsURL links to the producer's own documentation. - AnnotationDocsURL = GroupName + "/docs-url" + AnnotationDocsURL = MetadataDomain + "/docs-url" // AnnotationInstructions names a ConfigMap in the same workspace as the // export whose keys are sections of "how to use this" — one per client @@ -163,7 +166,7 @@ const ( // A ConfigMap rather than more annotations because setup instructions // for several clients are paragraphs and code blocks, not a line — // and a ConfigMap is the one core object made for exactly that. - AnnotationInstructions = GroupName + "/instructions" + AnnotationInstructions = MetadataDomain + "/instructions" ) // OfferingClaim is one permission claim as the catalog shows it: what a diff --git a/marketplace/test/e2e/fixture_test.go b/marketplace/test/e2e/fixture_test.go index 475606e..9ed891d 100644 --- a/marketplace/test/e2e/fixture_test.go +++ b/marketplace/test/e2e/fixture_test.go @@ -61,7 +61,7 @@ import ( const ( exportsPath = "root:marketplace:controllers" catalogPath = "root:marketplace:catalog" - producersPath = "root:marketplace:providers" + producersPath = "root:marketplace:producers" ) // The three exports, by the name their APIBinding takes. diff --git a/mcp/README.md b/mcp/README.md index b24d45c..f4f7781 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -167,7 +167,7 @@ in the console, with the server, issuer, client id and their own workspace filled in. They come from [`config/apiexport/instructions.yaml`](config/apiexport/instructions.yaml), a ConfigMap the `mcp` APIExport names in its -`marketplace.platform.kcp.io/instructions` annotation; `init` applies it +`marketplace.kcp.hub/instructions` annotation; `init` applies it with the export, and the marketplace mirrors it into the catalog. To change what people are told, edit that file and apply it into the controllers workspace — no rebuild: diff --git a/mcp/config/apiexport/apiexport-mcp.yaml b/mcp/config/apiexport/apiexport-mcp.yaml index b1b47b3..f2723e9 100644 --- a/mcp/config/apiexport/apiexport-mcp.yaml +++ b/mcp/config/apiexport/apiexport-mcp.yaml @@ -22,11 +22,11 @@ metadata: name: mcp # Published to the marketplace catalog. The offering IS this export. annotations: - marketplace.platform.kcp.io/publish: "true" - marketplace.platform.kcp.io/display-name: MCP + marketplace.kcp.hub/publish: "true" + marketplace.kcp.hub/display-name: MCP # The ConfigMap below, in this same workspace: how to connect a client. - marketplace.platform.kcp.io/instructions: mcp-instructions - marketplace.platform.kcp.io/description: >- + marketplace.kcp.hub/instructions: mcp-instructions + marketplace.kcp.hub/description: >- The Model Context Protocol over this workspace. Enabling lets the MCP server act as you here; what a session can see is whatever your own access allows. diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json deleted file mode 100644 index eba28c3..0000000 --- a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"5.0.3","results":[[":ui/src/lib/instructions.test.ts",{"duration":3.922583000000003,"failed":false}],[":ui/src/lib/form.test.ts",{"duration":3.12662499999999,"failed":false}]]} \ No newline at end of file diff --git a/producer-quickstart/config/apiexport.yaml b/producer-quickstart/config/apiexport.yaml index a3028c9..d704a77 100644 --- a/producer-quickstart/config/apiexport.yaml +++ b/producer-quickstart/config/apiexport.yaml @@ -9,13 +9,13 @@ metadata: name: dbaas.example.com annotations: # Only the exact string "true" publishes. - marketplace.platform.kcp.io/publish: "true" - marketplace.platform.kcp.io/display-name: Databases - marketplace.platform.kcp.io/description: Managed databases, one object per instance. + marketplace.kcp.hub/publish: "true" + marketplace.kcp.hub/display-name: Databases + marketplace.kcp.hub/description: Managed databases, one object per instance. # A ConfigMap in this workspace (config/instructions.yaml): what the # console shows a consumer after they enable this. - marketplace.platform.kcp.io/instructions: dbaas-instructions - # marketplace.platform.kcp.io/docs-url: https://docs.example.com/dbaas + marketplace.kcp.hub/instructions: dbaas-instructions + # marketplace.kcp.hub/docs-url: https://docs.example.com/dbaas spec: resources: - name: databases diff --git a/tenancy/README.md b/tenancy/README.md index 71fa52e..e25e17e 100644 --- a/tenancy/README.md +++ b/tenancy/README.md @@ -203,6 +203,14 @@ run it twice and the second run is a no-op. Quote `"$KUBECONFIG"` — unset and unquoted, the shell drops the argument and `--kubeconfig` swallows the next flag, giving the baffling `load kubeconfig: stat --workspace-path`. +Platform administrators — whoever creates and deletes Tenants — are named +here too: `--platform-admin-user` and `--platform-admin-group` (both +repeatable) bind them to `tenancy.platform.kcp.io:platform-admin` in the +store, a role that may enter it and manage Tenants and nothing else. The +binding holds exactly the identities of the latest run, so dropping one +from the flags revokes it. In production, name a group your identity +provider asserts rather than individual users. + ### 5. Run the operator ```sh @@ -304,7 +312,7 @@ Workspace names are DNS labels; display names are arbitrary UTF-8. The - `slug` (default): lowercase the display name, collapse everything else to dashes ("Acme Corp" → `acme-corp`). On collision with a workspace owned by a different object, fall back to the slug plus a hash of the object's UID. - Ownership is tracked with a `tenancy.platform.kcp.io/owner-uid` label, so + Ownership is tracked with a `tenancy.kcp.hub/owner-uid` label, so reconciles converge without storing decisions anywhere. - `uid`: always name by UID hash. Opaque, but immune to display-name games. @@ -554,14 +562,21 @@ SelfTenancyReview scenarios authenticate with. identity exists because your IdP issued it a credential, and a `Membership` names it verbatim. A typo in a subject name is not an error, it is a grant nobody holds. -6. **There is no tenant self-service.** The virtual workspace serves - `SelfTenancyReview` and nothing else, so Projects and Memberships are - written straight to kcp by someone holding an admin kubeconfig — - `tenancyctl` included. A tenant cannot manage their own projects, - because the tier those objects live in names no tenant identity. The - intended shape is for those writes to arrive through the virtual - workspace, which would hold the only credential into that tier and - could enforce guards a direct write bypasses. +6. **Self-service is narrow, and mediated.** Besides `SelfTenancyReview`, + the virtual workspace accepts exactly three writes, each performed with + its own credential into the tenant tier rather than a grant to the + caller: + - `SelfTenantRequest` gives a caller their first tenant, with the + caller as its admin. It is off unless the deployment passes + `--self-service-tenants`, and `--self-service-tenant-groups` limits it + to groups the identity provider asserts. + - `SelfProjectRequest` creates a project in a tenant the caller + administers. The caller names the project and nothing else. + - `memberships` lists, grants and revokes access in tenants the caller + administers. + + Anything else — deleting a tenant or project, renaming one — is still + written straight to kcp by someone holding an admin kubeconfig. 7. **The operator is not an administrator, and that is load-bearing.** It holds no admin client; every write goes through one of the four exports, so its reach is exactly their claims. The flip side is that a workspace diff --git a/tenancy/cmd/init.go b/tenancy/cmd/init.go index 1134548..ea25182 100644 --- a/tenancy/cmd/init.go +++ b/tenancy/cmd/init.go @@ -41,6 +41,8 @@ func newInitCommand() *cobra.Command { workspaceType string serverUsers []string serverGroups []string + adminUsers []string + adminGroups []string timeout time.Duration ) @@ -115,7 +117,11 @@ root down. Idempotent: safe to run on every pod start and every upgrade.`, }); err != nil { return fmt.Errorf("bind the Tenant API into %s: %w", storePath, err) } - logger.Info("tenant store ready", "workspace", storePath) + if err := bootstrap.GrantPlatformAdmins(ctx, storeCfg, adminUsers, adminGroups); err != nil { + return fmt.Errorf("grant platform admins in %s: %w", storePath, err) + } + logger.Info("tenant store ready", "workspace", storePath, + "platformAdminUsers", adminUsers, "platformAdminGroups", adminGroups) } if tenantsWorkspace != "" { tenantsPath, err = accessbootstrap.JoinWorkspacePath(workspacePrefix, tenantsWorkspace) @@ -190,6 +196,11 @@ root down. Idempotent: safe to run on every pod start and every upgrade.`, "or the virtual workspace does not run as this bootstrap's own credential.") cmd.Flags().StringSliceVar(&serverGroups, "server-group", nil, "Additional Group to grant the server role to, repeatable.") + cmd.Flags().StringSliceVar(&adminUsers, "platform-admin-user", nil, + "User who may manage Tenants in the store workspace, repeatable. Bound to a role "+ + "that allows exactly that; anyone left off the list on a later run loses it.") + cmd.Flags().StringSliceVar(&adminGroups, "platform-admin-group", nil, + "Group, as the identity provider asserts it, whose members may manage Tenants, repeatable.") cmd.Flags().DurationVar(&timeout, "timeout", 3*time.Minute, "Overall time budget for installing and verifying.") diff --git a/tenancy/pkg/bootstrap/bootstrap.go b/tenancy/pkg/bootstrap/bootstrap.go index 9c84248..a9ed29c 100644 --- a/tenancy/pkg/bootstrap/bootstrap.go +++ b/tenancy/pkg/bootstrap/bootstrap.go @@ -411,6 +411,41 @@ func applyStoreLocator(ctx context.Context, kube kubernetes.Interface, users, gr }, }, } + return applyRoleAndBinding(ctx, kube, role, users, groups) +} + +// GrantPlatformAdmins makes the given identities administrators of the +// tenant registry cfg points at: they may enter it and manage Tenants, +// and nothing else. It is applied even with no identities, so that +// dropping someone from the list revokes them. +func GrantPlatformAdmins(ctx context.Context, cfg *rest.Config, users, groups []string) error { + kube, err := kubernetes.NewForConfig(cfg) + if err != nil { + return fmt.Errorf("build kubernetes client: %w", err) + } + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: tenancyv1alpha1.GroupName + ":platform-admin"}, + Rules: []rbacv1.PolicyRule{ + { + Verbs: []string{"access"}, + NonResourceURLs: []string{"/"}, + }, + { + APIGroups: []string{tenancyv1alpha1.GroupName}, + Resources: []string{"tenants"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + }, + } + return applyRoleAndBinding(ctx, kube, role, users, groups) +} + +// applyRoleAndBinding creates or updates a ClusterRole and a binding of the +// same name holding exactly the given subjects. Updates change only rules, +// subjects and roleRef on the stored object: kcp annotates these objects +// itself, and writing back a copy without its internal.kcp.io annotations +// is rejected as modifying reserved metadata. +func applyRoleAndBinding(ctx context.Context, kube kubernetes.Interface, role *rbacv1.ClusterRole, users, groups []string) error { if _, err := kube.RbacV1().ClusterRoles().Create(ctx, role, metav1.CreateOptions{}); err != nil { if !apierrors.IsAlreadyExists(err) { return err @@ -480,47 +515,7 @@ func applyServerIdentity(ctx context.Context, kube kubernetes.Interface, users, }, }, } - if _, err := kube.RbacV1().ClusterRoles().Create(ctx, role, metav1.CreateOptions{}); err != nil { - if !apierrors.IsAlreadyExists(err) { - return err - } - if _, err := kube.RbacV1().ClusterRoles().Update(ctx, role, metav1.UpdateOptions{}); err != nil { - return err - } - } - - binding := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: tenancyv1alpha1.GroupName + ":server"}, - RoleRef: rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "ClusterRole", - Name: role.Name, - }, - } - for _, u := range users { - binding.Subjects = append(binding.Subjects, rbacv1.Subject{ - APIGroup: rbacv1.GroupName, Kind: "User", Name: u, - }) - } - for _, g := range groups { - binding.Subjects = append(binding.Subjects, rbacv1.Subject{ - APIGroup: rbacv1.GroupName, Kind: "Group", Name: g, - }) - } - if _, err := kube.RbacV1().ClusterRoleBindings().Create(ctx, binding, metav1.CreateOptions{}); err != nil { - if !apierrors.IsAlreadyExists(err) { - return err - } - existing, err := kube.RbacV1().ClusterRoleBindings().Get(ctx, binding.Name, metav1.GetOptions{}) - if err != nil { - return err - } - binding.ResourceVersion = existing.ResourceVersion - if _, err := kube.RbacV1().ClusterRoleBindings().Update(ctx, binding, metav1.UpdateOptions{}); err != nil { - return err - } - } - return nil + return applyRoleAndBinding(ctx, kube, role, users, groups) } // WaitForEndpointSliceURLs reports the virtual workspace URLs one endpoint diff --git a/tenancy/pkg/cli/login.go b/tenancy/pkg/cli/login.go index b97dfb8..7c3ebb4 100644 --- a/tenancy/pkg/cli/login.go +++ b/tenancy/pkg/cli/login.go @@ -142,6 +142,13 @@ func anyCachedToken(path string) (string, bool) { return found, found != "" } +// CachedToken returns the token `login` cached in the default location, or +// "" when there is none, it expired, or more than one provider is cached. +func CachedToken() string { + token, _ := anyCachedToken(defaultTokenCachePath()) + return token +} + // oidcEndpoints is the slice of the discovery document this needs. type oidcEndpoints struct { Issuer string `json:"issuer"` diff --git a/tenancy/pkg/operator/workspaces.go b/tenancy/pkg/operator/workspaces.go index f3d0fa2..c310282 100644 --- a/tenancy/pkg/operator/workspaces.go +++ b/tenancy/pkg/operator/workspaces.go @@ -27,15 +27,13 @@ import ( corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" kcptenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" - - tenancyv1alpha1 "github.com/kcp-dev/contrib-virtual-workspaces/tenancy/sdk/apis/tenancy/v1alpha1" ) // ownerUIDLabel ties a provisioned Workspace to the Tenant or Project that // asked for it. It is how a reconcile recognizes its own workspace among // naming candidates, and how a collision with someone else's workspace is // told apart from an earlier run of the same reconcile. -const ownerUIDLabel = tenancyv1alpha1.GroupName + "/owner-uid" +const ownerUIDLabel = "tenancy.kcp.hub/owner-uid" // provisioned describes the workspace a tenant or project ended up with. type provisioned struct { diff --git a/tenancy/pkg/server/server.go b/tenancy/pkg/server/server.go index 8c54424..1a06c68 100644 --- a/tenancy/pkg/server/server.go +++ b/tenancy/pkg/server/server.go @@ -226,8 +226,7 @@ func Run(ctx context.Context, o *Options) error { if err != nil && !errors.Is(err, context.Canceled) { return fmt.Errorf("directory provider failed: %w", err) } - <-serveErr - return nil + return <-serveErr case err := <-serveErr: return err } diff --git a/ui/README.md b/ui/README.md index 800a2c1..57fbea7 100644 --- a/ui/README.md +++ b/ui/README.md @@ -55,8 +55,10 @@ their own idea of who you are. It is shown when kcp answers that the identity may create Tenants in `root:tenancy:store`, and a tenant admin is not a platform admin — alice is not even allowed to ask the question there, which the UI reads as the -"no" it is. To see that page, sign in as an identity the platform has -granted rights in those workspaces. +"no" it is. To see that page, sign in as `admin@platform.example.com`, +which the tenancy and marketplace init steps bind to their platform-admin +roles (`--platform-admin-user`). Registering a producer additionally +needs the marketplace one; the button is hidden without it. ### Configuration diff --git a/ui/src/pages/AdminPage.vue b/ui/src/pages/AdminPage.vue index dcd6fd1..7e6a636 100644 --- a/ui/src/pages/AdminPage.vue +++ b/ui/src/pages/AdminPage.vue @@ -3,9 +3,9 @@ // provisioning parent their workspaces are created under. This menu // appears only when kcp answered that this identity may write there — // and if that answer is somehow wrong, kcp still refuses the writes. -import { onMounted, ref } from 'vue' +import { computed, onMounted, ref } from 'vue' import { useSession } from '@/stores/session' -import { list, ApiError } from '@/lib/api' +import { list, createObject, canI, ApiError } from '@/lib/api' import PageHeader from '@/components/PageHeader.vue' import Panel from '@/components/Panel.vue' import StateBlock from '@/components/StateBlock.vue' @@ -38,6 +38,44 @@ async function load() { } } +const mayRegister = ref(false) +const showForm = ref(false) +const newName = ref('') +const newDisplayName = ref('') +const creating = ref(false) +const note = ref('') +const noteError = ref(false) + +// The producer's name is also its workspace's name. +const nameValid = computed(() => /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/.test(newName.value.trim())) + +async function registerProducer() { + const name = newName.value.trim() + if (!nameValid.value) return + creating.value = true + note.value = '' + try { + await createObject(CATALOG, 'marketplace.platform.kcp.io/v1alpha1', 'producers', { + apiVersion: 'marketplace.platform.kcp.io/v1alpha1', + kind: 'Producer', + metadata: { name }, + spec: { displayName: newDisplayName.value.trim() || name, mode: 'provisioned' }, + }) + noteError.value = false + note.value = `Registered producer ${name}. Its workspace appears below once the marketplace operator has created it.` + newName.value = '' + newDisplayName.value = '' + showForm.value = false + await load() + } catch (e) { + noteError.value = true + note.value = + e instanceof ApiError && e.status === 409 ? `A producer named ${name} already exists.` : describe(e) + } finally { + creating.value = false + } +} + function describe(e: unknown): string { if (e instanceof ApiError && e.forbidden) return 'Forbidden.' return e instanceof Error ? e.message : String(e) @@ -52,7 +90,14 @@ function field(o: Record, path: string[]): string { return cur === undefined || cur === null || cur === '' ? '—' : String(cur) } -onMounted(load) +onMounted(async () => { + load() + mayRegister.value = await canI(CATALOG, { + group: 'marketplace.platform.kcp.io', + resource: 'producers', + verb: 'create', + }) +})