Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions charts/krm-foyer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

Installs [krm-foyer](../../README.md): one Deployment with one replica, a Service for the origin, a Service for metrics, the pod's service account, and
a NetworkPolicy that admits traffic to krm-foyer's ports alone.
With `sharedWatches.resources` set, it also creates the
[shared-watch identity](../../docs/watches.md) and its grants.
With `sharedWatches.resources` or `sharedWatches.namespacedResources` set, it also
creates the [shared-watch identity](../../docs/watches.md) and its grants: a ClusterRole
for the first, a Role in each named namespace for the second.

```bash
kubectl create namespace krm-foyer
Expand Down
15 changes: 13 additions & 2 deletions charts/krm-foyer/templates/deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{{- $tls := ne .Values.tls.secretName "" }}
{{- $scheme := ternary "https" "http" $tls }}
{{- $shared := gt (len .Values.sharedWatches.resources) 0 }}
{{- $shared := or .Values.sharedWatches.resources .Values.sharedWatches.namespacedResources }}
{{- $login := include "krm-foyer.login" . }}
apiVersion: apps/v1
kind: Deployment
Expand Down Expand Up @@ -88,7 +88,18 @@ spec:
{{- . | trim | nindent 12 }}
{{- end }}
{{- if $shared }}
- {{ printf "-shared-watch-resources=%s" (join "," .Values.sharedWatches.resources) | quote }}
{{- with .Values.sharedWatches.resources }}
- {{ printf "-shared-watch-resources=%s" (join "," .) | quote }}
{{- end }}
{{- with .Values.sharedWatches.namespacedResources }}
{{- $pairs := list }}
{{- range $namespace, $resources := . }}
{{- range $resources }}
{{- $pairs = append $pairs (printf "%s/%s" $namespace .) }}
{{- end }}
{{- end }}
- {{ printf "-shared-watch-namespaced-resources=%s" (join "," $pairs) | quote }}
{{- end }}
- -shared-watch-token-file=/etc/krm-foyer/shared-watch/token
{{- with include "krm-foyer.flags" (list .Values.sharedWatches.tuning (list
(list "recheckInterval" "shared-watch-recheck-interval")
Expand Down
61 changes: 50 additions & 11 deletions charts/krm-foyer/templates/shared-watches.yaml
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
{{- if and .Values.sharedWatches.resources .Values.sharedWatches.serviceAccount.create }}
{{- if and (or .Values.sharedWatches.resources .Values.sharedWatches.namespacedResources) .Values.sharedWatches.serviceAccount.create }}
{{- $name := include "krm-foyer.sharedAccountName" . }}
{{- $labels := include "krm-foyer.labels" . }}
# The shared-watch identity (docs/watches.md): it may list and watch the shared
# resources, and ask the API server whether a user may (create subjectaccessreviews),
# and nothing else. Not system:auth-delegator, which would also let it review tokens.
# Its binding has a new name: a binding's roleRef cannot change, so an upgrade from
# the auth-delegator binding replaces it rather than editing it.
# resources, each where it is shared, and ask the API server whether a user may
# (create subjectaccessreviews), and nothing else. Not system:auth-delegator, which
# would also let it review tokens. Its binding has a new name: a binding's roleRef
# cannot change, so an upgrade from the auth-delegator binding replaces it rather
# than editing it.
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ $name }}
labels:
{{- include "krm-foyer.labels" . | nindent 4 }}
{{- $labels | nindent 4 }}
automountServiceAccountToken: false
---
# Its token, for the pod to mount. Kubernetes fills this Secret in.
Expand All @@ -19,17 +21,18 @@ kind: Secret
metadata:
name: {{ $name }}-token
labels:
{{- include "krm-foyer.labels" . | nindent 4 }}
{{- $labels | nindent 4 }}
annotations:
kubernetes.io/service-account.name: {{ $name }}
type: kubernetes.io/service-account-token
{{- if .Values.sharedWatches.resources }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ $name }}-watches
labels:
{{- include "krm-foyer.labels" . | nindent 4 }}
{{- $labels | nindent 4 }}
rules:
{{- range .Values.sharedWatches.resources }}
{{- $parts := splitn "." 2 . }}
Expand All @@ -43,7 +46,7 @@ kind: ClusterRoleBinding
metadata:
name: {{ $name }}-watches
labels:
{{- include "krm-foyer.labels" . | nindent 4 }}
{{- $labels | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
Expand All @@ -52,13 +55,49 @@ subjects:
- kind: ServiceAccount
name: {{ $name }}
namespace: {{ .Release.Namespace }}
{{- end }}
{{- /* A Role in each namespace a namespaced resource is shared in, never a
ClusterRole: these are what the identity must not read cluster-wide. */}}
{{- range $namespace, $resources := .Values.sharedWatches.namespacedResources }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ $name }}-watches
namespace: {{ $namespace }}
labels:
{{- $labels | nindent 4 }}
rules:
{{- range $resources }}
{{- $parts := splitn "." 2 . }}
- apiGroups: [{{ $parts._1 | default "" | quote }}]
resources: [{{ $parts._0 | quote }}]
verbs: [list, watch]
{{- end }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ $name }}-watches
namespace: {{ $namespace }}
labels:
{{- $labels | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ $name }}-watches
subjects:
- kind: ServiceAccount
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
{{- end }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ $name }}-subject-access-reviews
labels:
{{- include "krm-foyer.labels" . | nindent 4 }}
{{- $labels | nindent 4 }}
rules:
- apiGroups: [authorization.k8s.io]
resources: [subjectaccessreviews]
Expand All @@ -69,7 +108,7 @@ kind: ClusterRoleBinding
metadata:
name: {{ $name }}-subject-access-reviews
labels:
{{- include "krm-foyer.labels" . | nindent 4 }}
{{- $labels | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
Expand Down
15 changes: 15 additions & 0 deletions charts/krm-foyer/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,21 @@
"description": "A resource as kubectl names it: plural, then the group if it has one."
}
},
"namespacedResources": {
"type": "object",
"description": "Resources shared in the namespace each key names, and nowhere else.",
"propertyNames": { "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", "maxLength": 63 },
"additionalProperties": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9]([-a-z0-9.]*[a-z0-9])?)?$",
"description": "A resource as kubectl names it: plural, then the group if it has one."
}
}
},
"serviceAccount": {
"type": "object",
"additionalProperties": false,
Expand Down
12 changes: 9 additions & 3 deletions charts/krm-foyer/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,16 @@ bounds: {}
# them: notes.hello.krm-foyer.example, configmaps. Empty opens every watch as its user.
sharedWatches:
resources: []
# Resources shared in some namespaces only, by namespace: {voter:
# [rolebindings.rbac.authorization.k8s.io]}. The identity may list and watch them in
# those namespaces alone (a Role in each, not a ClusterRole), and a stream of one
# anywhere else is opened as its user. For what the identity must not read
# cluster-wide, such as RBAC.
namespacedResources: {}
# The identity shared watches are opened with. Created, it may list and watch the
# resources above and create subjectaccessreviews, and nothing else; its token is a
# Secret named <name>-token. Not created, the account named, and a token Secret named
# <name>-token, must already exist.
# resources above, each where it is shared, and create subjectaccessreviews, and
# nothing else; its token is a Secret named <name>-token. Not created, the account
# named, and a token Secret named <name>-token, must already exist.
serviceAccount:
create: true
# Empty is <fullname>-shared.
Expand Down
91 changes: 88 additions & 3 deletions cmd/krm-foyer/chart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type rendered struct {
secrets []corev1.Secret
services []corev1.Service
clusterRoles []rbacv1.ClusterRole
roles []rbacv1.Role
bindings []rbacv1.ClusterRoleBinding
roleBindings []rbacv1.RoleBinding
networkPolicies []networkingv1.NetworkPolicy
Expand Down Expand Up @@ -104,6 +105,8 @@ func render(t *testing.T, valueFiles []string, set ...string) rendered {
target = appendNew(&r.services)
case "ClusterRole":
target = appendNew(&r.clusterRoles)
case "Role":
target = appendNew(&r.roles)
case "ClusterRoleBinding":
target = appendNew(&r.bindings)
case "RoleBinding":
Expand Down Expand Up @@ -314,6 +317,9 @@ func TestChartRefusesValues(t *testing.T) {
"a bound of zero": {[]string{"bounds.maxStreams=0"}, "/bounds/maxStreams"},
"a resource with a wildcard": {[]string{"sharedWatches.resources={*}"}, "/sharedWatches/resources/0"},
"a resource with a subresource": {[]string{"sharedWatches.resources={pods/log}"}, "/sharedWatches/resources/0"},
"a namespace that is no label": {[]string{"sharedWatches.namespacedResources.Voter={configmaps}"}, "invalid propertyName 'Voter'"},
"a namespace with no resource": {[]string{"sharedWatches.namespacedResources.voter=null"}, "/sharedWatches/namespacedResources/voter"},
"a namespaced subresource": {[]string{"sharedWatches.namespacedResources.voter={pods/log}"}, "/sharedWatches/namespacedResources/voter/0"},
"two replicas": {[]string{"replicaCount=2"}, "replicaCount"},
"plain HTTP open to anyone": {[]string{"networkPolicy.from=null"}, "networkPolicy.from must name the ingress"},
"an account named by no one": {[]string{"serviceAccount.create=false"}, "/serviceAccount"},
Expand Down Expand Up @@ -354,6 +360,8 @@ func TestChartGivesThePodsAccountNothing(t *testing.T) {
}{
"defaults": {nil, ""},
"shared watches": {[]string{"sharedWatches.resources={notes.hello.krm-foyer.example,configmaps}"}, "krm-foyer-shared-token"},
"namespaced shared watches": {[]string{"sharedWatches.resources=null",
"sharedWatches.namespacedResources.voter={rolebindings.rbac.authorization.k8s.io}"}, "krm-foyer-shared-token"},
"an account it brings": {[]string{"serviceAccount.create=false", "serviceAccount.name=app",
"sharedWatches.resources={configmaps}"}, "krm-foyer-shared-token"},
"a shared account it brings": {[]string{"sharedWatches.resources={configmaps}",
Expand Down Expand Up @@ -393,16 +401,20 @@ func TestChartGivesThePodsAccountNothing(t *testing.T) {
t.Fatalf("Secret %s holds the pod's own token", s.Name)
}
}
if len(r.roleBindings) > 0 {
t.Fatal("the chart binds a Role; check it does not grant the pod's account anything, then allow it here")
}
for _, b := range r.bindings {
for _, s := range b.Subjects {
if s.Name == account {
t.Fatalf("%s binds the pod's account to %s", b.Name, b.RoleRef.Name)
}
}
}
for _, b := range r.roleBindings {
for _, s := range b.Subjects {
if s.Name == account {
t.Fatalf("%s in %s binds the pod's account to %s", b.Name, b.Namespace, b.RoleRef.Name)
}
}
}
})
}
}
Expand Down Expand Up @@ -448,6 +460,79 @@ func TestChartGrantsTheSharedIdentityOnlyWhatItNeeds(t *testing.T) {
}
}

// A resource shared in some namespaces is granted there alone: a Role and a
// RoleBinding in each, and no ClusterRole that lets the identity list or watch it
// anywhere else. RoleBindings are why this exists: a ClusterRole for them would let
// the identity read every grant in the cluster. krm-foyer is told the same
// namespaces, so it sends no stream elsewhere to the identity.
func TestChartGrantsANamespacedResourceInItsNamespacesAlone(t *testing.T) {
r := render(t, nil, "sharedWatches.resources={configmaps}",
"sharedWatches.namespacedResources.voter={rolebindings.rbac.authorization.k8s.io}",
"sharedWatches.namespacedResources.team={rolebindings.rbac.authorization.k8s.io,notes.hello.krm-foyer.example}")
for _, cr := range r.clusterRoles {
for _, rule := range cr.Rules {
if slices.Contains(rule.Resources, "rolebindings") || slices.Contains(rule.Resources, "notes") {
t.Fatalf("ClusterRole %s grants %+v cluster-wide", cr.Name, rule)
}
}
}
want := map[string][]rbacv1.PolicyRule{
"team": {
{APIGroups: []string{"rbac.authorization.k8s.io"}, Resources: []string{"rolebindings"}, Verbs: []string{"list", "watch"}},
{APIGroups: []string{"hello.krm-foyer.example"}, Resources: []string{"notes"}, Verbs: []string{"list", "watch"}},
},
"voter": {
{APIGroups: []string{"rbac.authorization.k8s.io"}, Resources: []string{"rolebindings"}, Verbs: []string{"list", "watch"}},
},
}
if len(r.roles) != len(want) {
t.Fatalf("%d Roles, want %d", len(r.roles), len(want))
}
for _, role := range r.roles {
rules, ok := want[role.Namespace]
if !ok || len(role.Rules) != len(rules) {
t.Fatalf("Role %s in %q has %+v", role.Name, role.Namespace, role.Rules)
}
for i := range rules {
got := role.Rules[i]
if !slices.Equal(got.APIGroups, rules[i].APIGroups) || !slices.Equal(got.Resources, rules[i].Resources) ||
!slices.Equal(got.Verbs, rules[i].Verbs) || len(got.ResourceNames) > 0 || len(got.NonResourceURLs) > 0 {
t.Fatalf("Role in %s, rule %d is %+v, want %+v", role.Namespace, i, got, rules[i])
}
}
}
bound := map[string]bool{}
for _, b := range r.roleBindings {
if len(b.Subjects) != 1 || b.Subjects[0].Name != "krm-foyer-shared" || b.Subjects[0].Namespace != "krm-foyer" ||
b.RoleRef.Kind != "Role" || b.RoleRef.Name != "krm-foyer-shared-watches" {
t.Fatalf("RoleBinding %s in %s binds %+v to %+v", b.Name, b.Namespace, b.Subjects, b.RoleRef)
}
bound[b.Namespace] = true
}
if !bound["voter"] || !bound["team"] || len(bound) != 2 {
t.Fatalf("RoleBindings in %v", bound)
}

sh := parseRendered(t, r).login.shared
if len(sh.Resources) != 1 || len(sh.NamespacedResources) != 2 {
t.Fatalf("shared %+v", sh)
}
namespaces := map[string][]string{}
for _, nr := range sh.NamespacedResources {
namespaces[nr.Resource] = nr.Namespaces
}
if !slices.Equal(namespaces["rolebindings"], []string{"team", "voter"}) || !slices.Equal(namespaces["notes"], []string{"team"}) {
t.Fatalf("krm-foyer is told %v", namespaces)
}

// Without a cluster-wide resource there is no cluster-wide watch grant at all.
only := render(t, nil, "sharedWatches.resources=null",
"sharedWatches.namespacedResources.voter={rolebindings.rbac.authorization.k8s.io}")
if len(only.clusterRoles) != 1 || only.clusterRoles[0].Name != "krm-foyer-shared-subject-access-reviews" {
t.Fatalf("ClusterRoles %+v, want the reviews one alone", only.clusterRoles)
}
}

// With plain HTTP, the hop from the ingress carries session cookies, so only the
// ingress may reach the origin port (docs/ingress.md); the chart refuses to render
// without it being named. With TLS the origin is open, and metrics are open unless
Expand Down
Loading
Loading