Documentation
¶
Overview ¶
Package kubeshare is a Kubernetes cluster shared with a cloop hub through a least-privilege ServiceAccount (Task 20416).
Why it exists ¶
The clusters enterprises run authenticate people through exec credential plugins — kubelogin on AKS, `aws eks get-token` on EKS, the GKE plugin — and the hub refuses such a kubeconfig, because running the plugin there would execute a program named in an uploaded file on the control-plane host. The refusal used to be the end of it: the developer had to create a ServiceAccount, choose and bind roles, mint a token and assemble a kubeconfig by hand, and the token then expired mid-run because managed API servers cap how long one may live.
A share does that work from the developer's own terminal (`cloop remote kube share`, which runs the plugin there through pkg/kubeexec) and leaves the hub holding only a static kubeconfig for a ServiceAccount that can do exactly what the developer chose:
- a labelled ServiceAccount in a namespace the developer names;
- a RoleBinding in each shared namespace to the cluster's own `view` role, or `edit` for write access — never a ClusterRoleBinding unless asked;
- a bounded token, minted through the TokenRequest API.
When the hub runs the Kubernetes access monitor, the share renews itself: one more Role lets the ServiceAccount create a token for itself and nobody else (resourceNames), and the hub uses it to mint the next token before the current one expires. The monitor refuses the serviceaccounts/token subresource to every sandbox (pkg/kubeguard), so the permission is the hub's to spend and never the workload's. Without the monitor the token would sit in the sandbox, so no such Role is installed and the developer renews with `cloop remote kube renew`.
What this package is, and is not ¶
The plan of objects, the few Kubernetes API calls a share makes, the token's claims, the static kubeconfig, the metadata the hub keeps on the secret, and the hub's renewal. It never runs a credential plugin and never reads a file a kubeconfig names: the hub imports it, and tests/security holds it to that.
Index ¶
- Constants
- Variables
- func CAFingerprint(caData []byte) string
- func ClearStopped(meta map[string]string)
- func CollectionPath(o Object) string
- func DeleteCommand(shareID, ns string) string
- func DeleteCommandFor(objs []Object) string
- func DescribePlugin(command string, args []string) string
- func Expiry(expires, now time.Time) string
- func FailureText(reason string) string
- func FindCommand(shareID, ns string) string
- func FormatObjects(objs []Object) string
- func IsConflict(err error) bool
- func IsForbidden(err error) bool
- func IsNotFound(err error) bool
- func IsShare(meta map[string]string) bool
- func IsShareServiceAccount(name, id string) bool
- func IsUnauthorized(err error) bool
- func Lapsed(err error) bool
- func Mint(ctx context.Context, c *Client, p *Plan) (Token, Claims, error)
- func NewID() (string, error)
- func ObjectPath(o Object) string
- func OwnerMatches(annotation, owner string) bool
- func RecordFailure(meta map[string]string, reason string, at time.Time, lapsed bool)
- func RecordRenewal(meta map[string]string, c Claims, by string, at time.Time)
- func RecordStopped(meta map[string]string, reason string, at time.Time)
- func RecordToken(meta map[string]string, c Claims)
- func RenderKubeconfig(p KubeconfigParams) ([]byte, error)
- func ReplaceToken(kubeconfig []byte, token string) ([]byte, error)
- func ShareCommand(name, context, namespace, hub string) string
- func ShortDuration(d time.Duration) string
- func UploadRefusal(raw []byte, name, hub string) string
- func ValidNamespace(s string) bool
- func ValidServiceAccountName(s string) bool
- func ValidShareID(s string) bool
- func VerifyServiceAccount(ctx context.Context, c *Client, s Share) error
- type APIError
- type Access
- type Claims
- type Client
- func (c *Client) Close()
- func (c *Client) Create(ctx context.Context, m Manifest) error
- func (c *Client) Delete(ctx context.Context, o Object) error
- func (c *Client) DeleteExact(ctx context.Context, o Object, meta ObjectMeta) error
- func (c *Client) Get(ctx context.Context, o Object) (ObjectMeta, error)
- func (c *Client) ListLabelled(ctx context.Context, kind, namespace, shareID string) ([]Labelled, error)
- func (c *Client) RequestToken(ctx context.Context, namespace, serviceAccount string, ttl time.Duration) (Token, error)
- func (c *Client) Server() string
- func (c *Client) WhoAmI(ctx context.Context) (username string, ok bool, err error)
- type KubeconfigParams
- type Labelled
- type Manifest
- type Object
- type ObjectMeta
- type Origin
- type Plan
- type PluginUse
- type Removal
- type Renewal
- type Renewed
- type Request
- type Share
- func (s Share) Due(now time.Time) bool
- func (s Share) ExpectedObjects() []Object
- func (s Share) Expired(now time.Time) bool
- func (s Share) Lifetime() time.Duration
- func (s Share) Metadata() map[string]string
- func (s Share) SameCluster(server string, caData []byte) error
- func (s Share) SharedNamespaces() string
- type Token
Constants ¶
const ( // MinTTL is the shortest token the TokenRequest API issues. MinTTL = 10 * time.Minute // MaxTTL is the longest token a share asks for. A share exists so that // nobody hands a sandbox a credential that outlives the work; a token for // longer than a week is that credential. MaxTTL = 7 * 24 * time.Hour // DefaultTTL is what a share asks for when --ttl is not given. DefaultTTL = 24 * time.Hour )
Token lifetimes.
const ( // share at all. MetaShare = "kube.share" // MetaRenewal is RenewalHub or RenewalManual. MetaRenewal = secretbroker.KubeShareMetaRenewal // MetaServiceAccount is "<namespace>/<name>". MetaServiceAccount = "kube.service_account" // MetaNamespaces lists the shared namespaces, comma-separated; "*" for a // share bound cluster-wide. MetaNamespaces = "kube.namespaces" // MetaAccess is read or write. MetaAccess = "kube.access" // MetaServer is the API server the share's kubeconfig names. MetaServer = "kube.server" // MetaContext is the developer's kubeconfig context the share was made // from, which renew and revoke default to. MetaContext = "kube.context" // MetaObjects lists the objects the share created, for revoke. MetaObjects = "kube.objects" // MetaIssuedAt and MetaExpiresAt bound the token the secret holds. MetaIssuedAt = "kube.issued_at" MetaExpiresAt = "kube.expires_at" // MetaRenewedAt, MetaRenewedBy and MetaRenewals describe the last renewal. MetaRenewedAt = "kube.renewed_at" MetaRenewedBy = "kube.renewed_by" MetaRenewals = "kube.renewals" // MetaRenewError and MetaRenewErrorAt hold why the last renewal attempt // failed, until one succeeds. MetaRenewError = "kube.renew_error" MetaRenewErrorAt = "kube.renew_error_at" // MetaRenewState is "lapsed" once the token expired with no renewal: the // hub cannot renew it any more, only its owner can — or "stopped" while // the hub refuses to renew it because its owner is denied on the hub. MetaRenewState = "kube.renew_state" // MetaOwner is who made the share: the hub identity that uploaded it, as // the hub's owner key (a lowercased email, or "sub:" and the subject). // Written by the hub, never taken from the client (Task 20420): revoke // and renew act only on cluster objects whose owner annotation names it. MetaOwner = "kube.owner" // MetaCA is the SHA-256 of the cluster CA the share's kubeconfig trusts, // hex, or CANone when it carries none. Written by the hub from the // kubeconfig itself: renew and revoke refuse a local context that trusts // another. MetaCA = "kube.ca_sha256" )
The metadata keys the hub keeps on a share's secret. None is a credential — metadata lands in audit rows and API responses — and the time fields are derived by the hub from the token itself, not trusted from the client.
const ( RenewStateLapsed = "lapsed" RenewStateStopped = "stopped" )
MetaRenewState's values.
const ( LabelManagedBy = "app.kubernetes.io/managed-by" AnnotationName = "cloop.dev/share-name" AnnotationHub = "cloop.dev/hub" AnnotationOwner = "cloop.dev/owner" AnnotationAccess = "cloop.dev/access" AnnotationCreated = "cloop.dev/created-at" )
Labels and annotations on every object a share creates. The label is what revoke and an administrator find them by; the selector deliberately shares nothing with the executor's own (cloop.dev/managed), so the Kubernetes executor's orphan sweep can never mistake a share for something it owns.
const CANone = "none"
CANone is MetaCA for a kubeconfig that names no CA of its own and trusts the system's.
Variables ¶
var ( // ErrLapsed is a token that expired before it was renewed. Nothing the // hub holds can authenticate a renewal any more; only the share's owner, // with their own credentials, can mint the next token. ErrLapsed = errors.New("the share's token expired before it was renewed") // ErrUnrenewable is a stored kubeconfig a share could not have written: // it parses to no single ServiceAccount token. ErrUnrenewable = errors.New("the stored kubeconfig is not a renewable share") // ErrWrongToken is a pushed token that is not the share's ServiceAccount's. ErrWrongToken = errors.New("the token is not for the share's ServiceAccount") // ErrWrongServer is a pushed token minted at another API server than the // share's kubeconfig names, or through another CA — or one whose origin // nobody named (Task 20420). The hub never sends a token anywhere but the // server that minted it. ErrWrongServer = errors.New("the token was not minted by the share's API server") )
Errors a renewal distinguishes.
var ErrNotServiceAccountToken = errors.New("not a bounded ServiceAccount token")
ErrNotServiceAccountToken is a token that is not a bounded ServiceAccount token: not a JWT, not for a ServiceAccount, or without an expiry.
Functions ¶
func CAFingerprint ¶
CAFingerprint is MetaCA for a kubeconfig carrying caData.
func ClearStopped ¶
ClearStopped takes RecordStopped back, for an owner no longer denied.
func CollectionPath ¶
CollectionPath is where an object of o's kind is created.
func DeleteCommand ¶
DeleteCommand is the kubectl invocation that removes whatever carries the share's label in namespace ns. For the share's own terminal, cleaning up after a share it failed to finish; a revoke advises FindCommand instead, since anything labelled with the id would match.
func DeleteCommandFor ¶
DeleteCommandFor is the kubectl invocation that removes exactly objs.
func DescribePlugin ¶
DescribePlugin names an exec plugin the way its users know it: the program, and the platform it signs in to when that is recognisable.
func FailureText ¶
FailureText is a renewal failure as RecordFailure stores it: trimmed and bounded, so a caller can tell whether the stored one already says it.
func FindCommand ¶
FindCommand is the kubectl invocation that lists whatever carries the share's label in namespace ns, with the owner annotation to check each against before deleting it.
func FormatObjects ¶
FormatObjects renders objects for MetaObjects: "Kind:ns/name" entries, comma separated, "Kind:name" for a cluster-scoped one. Names and namespaces are DNS labels, so none of the separators can occur inside one.
func IsConflict ¶
func IsForbidden ¶
func IsNotFound ¶
IsNotFound, IsConflict, IsForbidden and IsUnauthorized classify an APIError.
func IsShareServiceAccount ¶
IsShareServiceAccount reports whether name is the ServiceAccount a share with this id creates: "cloop-<name>-<id>", as serviceAccountName builds it. The hub holds a share to it (Task 20420), so a share's token — whose claims nobody verified — cannot point renew at some other ServiceAccount.
func IsUnauthorized ¶
func Lapsed ¶
Lapsed reports whether err means the hub can never renew the share again on its own: the token expired, or the cluster no longer accepts it — the ServiceAccount was deleted, or the token revoked with it.
func OwnerMatches ¶
OwnerMatches reports whether an object's owner annotation names owner, the hub's record of who made the share (MetaOwner).
The annotation is written by the terminal that made the share: since Task 20420 the hub's own owner key, from /api/me; before that the email as the identity provider spelled it, or the bare subject. All three compare equal to the owner key they stand for. A share with no recorded owner — made on a hub with no identity provider — matches only an object carrying none.
func RecordFailure ¶
RecordFailure stamps a failed renewal attempt. lapsed marks a token that has expired, which only its owner can now replace.
func RecordRenewal ¶
RecordRenewal stamps a successful renewal: new times, who renewed, one more renewal, and any earlier failure cleared.
func RecordStopped ¶
RecordStopped stamps a share the hub stopped renewing, and why: its owner is denied on the hub (Task 20420). A lapsed share stays lapsed.
func RecordToken ¶
RecordToken writes the token's own claims into meta: the hub's half of the metadata, from the credential rather than from whoever sent it.
func RenderKubeconfig ¶
func RenderKubeconfig(p KubeconfigParams) ([]byte, error)
RenderKubeconfig builds the static kubeconfig a share stores on the hub.
func ReplaceToken ¶
ReplaceToken returns kubeconfig with the current context's user's token set to token, and nothing else changed — a renewal changes the credential, never the cluster it is for.
func ShareCommand ¶
ShareCommand is the command that shares context on hub as secret name. namespace is the context's default namespace, or empty for a placeholder.
func ShortDuration ¶
ShortDuration is shortDuration for callers rendering a share.
func UploadRefusal ¶
UploadRefusal returns why an uploaded kubeconfig cannot be stored, or "" when it can. name is the secret name the uploader chose and hub the hub's URL, so the command in the message is the one to run.
func ValidNamespace ¶
ValidNamespace reports whether s can name a namespace.
func ValidServiceAccountName ¶
ValidServiceAccountName reports whether s can name a ServiceAccount — and so one of a share's objects, which are all named after it.
func ValidShareID ¶
ValidShareID reports whether s can be a share's id.
func VerifyServiceAccount ¶
VerifyServiceAccount checks with the cluster that the share's ServiceAccount is one the share created: named as a share names it, and read back carrying the share's label and an owner annotation naming its owner (Task 20420). Renew mints a token for it with the developer's own credentials, so this is what stops a share's metadata pointing that at somebody else's ServiceAccount — kube-system's, say — and the hub then holding a token for it.
Types ¶
type Access ¶
type Access string
Access is what a share lets its ServiceAccount do in the shared namespaces.
func (Access) ClusterRole ¶
ClusterRole is the built-in role a binding of this access names.
type Claims ¶
type Claims struct {
Namespace string
ServiceAccount string
UID string
IssuedAt time.Time
ExpiresAt time.Time
}
Claims is what a ServiceAccount token says about itself.
func Inspect ¶
func Inspect(kubeconfig []byte) (*kubernetes.RESTConfig, Claims, error)
Inspect parses a stored share kubeconfig and returns its REST config and its token's claims.
func ParseClaims ¶
ParseClaims reads a token's claims. It refuses anything a share could not have minted: a share's token is always a JWT for a ServiceAccount, with an issue time and an expiry.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client talks to one API server as one credential.
func NewClient ¶
func NewClient(rc *kubernetes.RESTConfig) (*Client, error)
NewClient builds a client for rc. It performs no I/O.
func (*Client) Delete ¶
Delete deletes one object. Gone already is success: revoke is idempotent.
It deletes whatever the name holds. Only a share's own rollback, deleting what it created a moment ago, may use it; revoke deletes through DeleteExact, which names the object it read.
func (*Client) DeleteExact ¶
DeleteExact deletes o only if it is still the object read as meta: the API server checks the uid and resourceVersion preconditions and refuses (409) if either changed — so an object swapped, relabelled or re-annotated after it was checked is not the one deleted. Gone already is success.
func (*Client) Get ¶
Get reads an object's metadata. An object that is not one a share could have created is refused before any request is built from its name.
func (*Client) ListLabelled ¶
func (c *Client) ListLabelled(ctx context.Context, kind, namespace, shareID string) ([]Labelled, error)
ListLabelled lists the objects of a kind in namespace (or cluster-wide for a ClusterRoleBinding) that carry the share's label, with their metadata.
func (*Client) RequestToken ¶
func (c *Client) RequestToken(ctx context.Context, namespace, serviceAccount string, ttl time.Duration) (Token, error)
RequestToken mints a token for namespace/serviceAccount through the TokenRequest API, valid for ttl or whatever shorter time the cluster caps it at — managed API servers do cap it, which is the reason renewal exists.
type KubeconfigParams ¶
type KubeconfigParams struct {
// Name is the context's, the cluster's and the user's name: the share's.
Name string
// Server, CAData, TLSServerName, Insecure and ProxyURL describe the
// cluster exactly as the developer's kubeconfig did.
Server string
CAData []byte
TLSServerName string
Insecure bool
ProxyURL string
// Namespace is the context's default namespace.
Namespace string
// Token is the ServiceAccount token.
Token string
}
KubeconfigParams describe the static kubeconfig a share stores.
type Labelled ¶
type Labelled struct {
Object
Meta ObjectMeta
}
Labelled is an object a labelled listing found, with its metadata as listed.
type Manifest ¶
type Manifest struct {
Object
// Collection is the API path the object is created under.
Collection string
// Body is the object.
Body map[string]any
}
Manifest is one object to create: where to POST it and what to send.
type Object ¶
type Object struct {
Kind string // ServiceAccount, RoleBinding, Role, ClusterRoleBinding
Namespace string // empty for a ClusterRoleBinding
Name string
}
Object is one Kubernetes object a share created.
func Apply ¶
Apply creates the plan's objects in order. If one fails it deletes the ones it created, newest first, and returns the failure together with anything the rollback could not remove — a half-made share is worse than none, because a binding left behind grants access nobody is tracking.
An object that already exists is a failure too, never adopted: its name carries this share's id, so it can only be left over from something else.
func ParseObjects ¶
ParseObjects reads MetaObjects back. Malformed entries are skipped: revoke also finds objects by label, so a lost entry costs nothing.
func Remaining ¶
Remaining checks, after a removal, that nothing the share created is left in the namespaces it names — nothing carrying its label and its owner — and returns what is. An object carrying the label for another owner is not the share's to remove, and Remove reported it as refused.
func (Object) Valid ¶
Valid reports whether o is an object a share could have created: one of its four kinds, named as a share names things, in a namespace exactly when its kind lives in one. Every path revoke builds from an object goes through this, so a name carrying "/" or ".." can never steer a request elsewhere.
type ObjectMeta ¶
type ObjectMeta struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
UID string `json:"uid"`
ResourceVersion string `json:"resourceVersion"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
ObjectMeta is the part of an object a share reads back.
type Origin ¶
Origin is where a token pushed to the hub was minted: the API server the terminal's context reached and the CA it trusted there.
type Plan ¶
type Plan struct {
Request
// ServiceAccount is the ServiceAccount's name.
ServiceAccount string
// contains filtered or unexported fields
}
Plan is a validated request with every name decided.
func (*Plan) Objects ¶
Objects lists what the plan creates, in creation order: the ServiceAccount first, so every binding names something that exists.
func (*Plan) RenewRoleName ¶
RenewRoleName is the self-renewal Role's name, and its binding's.
type PluginUse ¶
type PluginUse struct {
User string
// Contexts are the contexts that use the user, sorted.
Contexts []string
// Namespace is the first such context's default namespace, if it has one.
Namespace string
// Command and Args are the exec plugin's; AuthProvider the auth-provider's
// name when that is what the user has instead.
Command string
Args []string
AuthProvider string
}
PluginUse is one kubeconfig user that signs in through a credential plugin.
func PluginUses ¶
PluginUses lists the users of raw that sign in through a credential plugin and are used by at least one context. A user no context names is not reported: it reaches nothing.
type Removal ¶
type Removal struct {
// Removed are the objects deleted.
Removed []Object
// Gone are the objects Remove looked for and the cluster no longer has.
Gone []Object
// Refused are objects Remove found and did not delete, because nothing
// read back from the cluster says this share created them, with why.
Refused map[Object]string
// Failed are the ones that could not be removed, with why — including a
// ServiceAccount Remove refused, which leaves every token minted for it
// valid until it expires.
Failed map[Object]error
}
Removal is what Remove did.
func Remove ¶
Remove deletes what a share created in the cluster, and nothing else.
Revoke runs with the developer's own credentials — often an administrator's — over a share whose metadata its uploader wrote. So nothing in that metadata is taken on trust (Task 20420). The candidates are what the share's plan creates (ExpectedObjects), what its metadata lists, and what carries its label in the namespaces it names; each is read back from the cluster and deleted only if it carries the share's label and an owner annotation naming the share's owner, and then by uid and resourceVersion, so an object that changed after it was read is not the one deleted. Anything else is left alone and reported in Refused.
The ServiceAccount goes first. Deleting it invalidates every token ever minted for it at once — the hub's, and any copy a workload kept — so the credential is dead before the first binding is touched, and a revoke that fails halfway leaves bindings to nothing rather than a live token. A ServiceAccount that exists and is not the share's is a failure, not just a refusal: whatever tokens the share holds stay valid until they expire.
type Renewal ¶
type Renewal string
Renewal says who keeps a share's token current.
const ( // RenewalHub: the hub mints the next token before the current one // expires, with the ServiceAccount's own self-renewal Role. Only offered // by a hub that runs the Kubernetes access monitor. RenewalHub Renewal = secretbroker.KubeShareRenewalHub // RenewalManual: the developer renews from their terminal with // `cloop remote kube renew`. RenewalManual Renewal = "manual" )
type Renewed ¶
type Renewed struct {
Kubeconfig []byte
// Claims are the new token's; Previous the replaced one's.
Claims Claims
Previous Claims
// Verified reports that the cluster itself confirmed who the new token
// is (Adopt on a cluster that answers SelfSubjectReview).
Verified bool
}
Renewed is a share's kubeconfig with a new token in it.
func Adopt ¶
func Adopt(ctx context.Context, kubeconfig []byte, token string, origin Origin, now time.Time) (*Renewed, error)
Adopt checks a token the share's owner minted and returns the stored kubeconfig carrying it. The token must have been minted at the API server the stored kubeconfig names, through the CA it trusts (origin), be a bounded token for the share's own ServiceAccount, still valid, and — where the cluster can say — authenticated by the cluster the share names as that ServiceAccount.
The origin check comes first because the confirmation sends the token to the stored server. That server is the share's uploader's to name, and the token was minted with somebody else's credentials, so a token is never sent to a server other than the one that minted it (Task 20420).
type Request ¶
type Request struct {
// Name is the secret's name on the hub, and part of every object's name.
Name string
// ID is the share's id; empty generates one.
ID string
// Namespaces are where the ServiceAccount is bound.
Namespaces []string
// ServiceAccountNamespace is where the ServiceAccount lives. Empty means
// the first shared namespace, for read access only.
ServiceAccountNamespace string
Access Access
// ClusterWide binds `view` with a ClusterRoleBinding instead of one
// RoleBinding per namespace. Read access only.
ClusterWide bool
TTL time.Duration
Renewal Renewal
// Hub and Owner annotate the objects, so whoever finds them in the
// cluster can tell where they came from and whose they are.
Hub string
Owner string
// Context is the developer's kubeconfig context, recorded for renew and
// revoke.
Context string
// Server is the API server the stored kubeconfig names.
Server string
}
Request is what `cloop remote kube share` asks for.
type Share ¶
type Share struct {
// on the hub — suspended, or offboarded with its secret kept under a
// legal hold (Task 20420). RenewError says why.
Stopped bool
Owner string
// recorded it, which renew and revoke cannot check a context against.
CAFingerprint string
}
Share is a share as the hub's secret metadata describes it.
func FromMetadata ¶
FromMetadata reads a share back. ok is false for a secret that is not one.
func (Share) Due ¶
Due reports whether a share's token should be renewed now: less than half its lifetime remains. Half rather than a fixed margin, so a hub that is down for less than half a token's life never loses the chain — a 24-hour token is renewed every 12 hours and survives a 12-hour outage, a 10-minute one is renewed every 5 minutes.
A share whose times are unknown is due, which makes the renewal read them from the token and record them.
func (Share) ExpectedObjects ¶
ExpectedObjects lists what a share's plan creates, derived from its ServiceAccount, namespaces and renewal rather than read from MetaObjects — the list the hub records for a share it has checked (Task 20420), and the first thing revoke looks for.
func (Share) Metadata ¶
Metadata renders a share's descriptive fields for the secret. The time and renewal-history fields are the hub's to write and are left to it.
func (Share) SameCluster ¶
SameCluster refuses a context that does not reach the share's cluster: its API server differs, or it trusts another CA (Task 20420). Renew and revoke act with the developer's own credentials, and the share's metadata — which names the context they default to — is its uploader's; acting on another cluster, or on the right URL through a TLS chain the share does not trust, is what this stops.
func (Share) SharedNamespaces ¶
SharedNamespaces renders the namespaces for a person: "team-a, team-b", or "every namespace" for a cluster-wide share.