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
6 changes: 6 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,14 @@ tasks:
cmds:
- task: controller-chart:lint
- task: controller-chart:template
- task: controller-chart:central-profile-check
- task: controller-chart:package

controller-chart:central-profile-check:
desc: Verify the central DNS profile cannot discover Gateway or Service sources.
cmds:
- ./scripts/check-central-profile.sh deploy/charts/dns-api

controller:rollout-status:
desc: Wait for the dns-api controller Deployment rollout.
vars:
Expand Down
37 changes: 23 additions & 14 deletions app/operator/cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import (
// +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits/status,verbs=get;watch;patch;update
// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointprovidercapabilities,verbs=get;list;watch
// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets,verbs=create;delete;get;list;watch;patch;update
// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets/finalizers,verbs=update
// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets/status,verbs=get;patch;update
// +kubebuilder:rbac:groups=endpoint.route53.dns.appthrust.io,resources=endpointrecordsetconversions,verbs=create
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;update
Expand All @@ -79,6 +80,8 @@ func main() {
var metricsAddr string
var probeAddr string
var leaderElection bool
var gatewayEndpointControllerEnabled bool
var serviceEndpointControllerEnabled bool
endpointRecordSetNamespace := envOrDefault("ENDPOINT_RECORDSET_NAMESPACE", "")
route53ControllerName := envOrDefault("ROUTE53_CONTROLLER_NAME", route53zoneunit.DefaultControllerName)
route53ProviderName := envOrDefault("ROUTE53_PROVIDER_NAME", route53v1alpha1.ProviderName)
Expand All @@ -90,6 +93,8 @@ func main() {
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.BoolVar(&leaderElection, "leader-elect", false, "Enable leader election for controller manager.")
flag.BoolVar(&gatewayEndpointControllerEnabled, "gateway-endpoint-controller-enabled", true, "Watch Gateway API resources and synthesize EndpointRecordSets from HTTPRoutes.")
flag.BoolVar(&serviceEndpointControllerEnabled, "service-endpoint-controller-enabled", true, "Watch Services and synthesize EndpointRecordSets from their endpoint annotations.")
flag.StringVar(&endpointRecordSetNamespace, "endpoint-recordset-namespace", endpointRecordSetNamespace, "Namespace where generated EndpointRecordSet and RecordSet resources are stored. Defaults to the source namespace.")
flag.StringVar(&route53ControllerName, "route53-controller-name", route53ControllerName, "ZoneClass.spec.controllerName handled by the Route 53 controller.")
flag.StringVar(&route53ProviderName, "route53-provider-name", route53ProviderName, "Provider.metadata.name handled by the Route 53 controller.")
Expand Down Expand Up @@ -199,22 +204,26 @@ func main() {
os.Exit(1)
}

if err := (&gatewayendpointcontroller.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
EndpointRecordSetNamespace: endpointRecordSetNamespace,
}).SetupWithManager(mgr); err != nil {
ctrl.Log.Error(err, "unable to set up Gateway Endpoint controller")
os.Exit(1)
if gatewayEndpointControllerEnabled {
if err := (&gatewayendpointcontroller.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
EndpointRecordSetNamespace: endpointRecordSetNamespace,
}).SetupWithManager(mgr); err != nil {
ctrl.Log.Error(err, "unable to set up Gateway Endpoint controller")
os.Exit(1)
}
}

if err := (&serviceendpointcontroller.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
EndpointRecordSetNamespace: endpointRecordSetNamespace,
}).SetupWithManager(mgr); err != nil {
ctrl.Log.Error(err, "unable to set up Service Endpoint controller")
os.Exit(1)
if serviceEndpointControllerEnabled {
if err := (&serviceendpointcontroller.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
EndpointRecordSetNamespace: endpointRecordSetNamespace,
}).SetupWithManager(mgr); err != nil {
ctrl.Log.Error(err, "unable to set up Service Endpoint controller")
os.Exit(1)
}
}

if err := corewebhook.SetupCoreValidationWebhookWithManager(mgr); err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ spec:
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
generatedRecordSetCount:
description: |-
GeneratedRecordSetCount is the number of generated RecordSets that are
represented by this status.
format: int32
type: integer
hostnameCount:
description: HostnameCount is the number of hostname status entries.
format: int32
Expand Down Expand Up @@ -357,9 +363,21 @@ spec:
rule: self.type != 'AAAA' || has(self.aaaa) || has(self.options)
- message: cname is required when type is CNAME
rule: self.type != 'CNAME' || has(self.cname)
generation:
description: |-
Generation is the current metadata generation of the generated RecordSet.
It is zero until the generated RecordSet has been observed.
format: int64
type: integer
name:
description: Name is the zone-relative DNS owner name.
type: string
observedGeneration:
description: |-
ObservedGeneration is the generated RecordSet generation reflected by its
mirrored status conditions.
format: int64
type: integer
ref:
description: Ref points to the generated Core RecordSet.
properties:
Expand Down
21 changes: 21 additions & 0 deletions app/operator/config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ rules:
- get
- list
- watch
- apiGroups:
- ""
resources:
- services
verbs:
- get
- list
- update
- watch
- apiGroups:
- ""
resources:
- services/finalizers
verbs:
- update
- apiGroups:
- cloudflare.dns.appthrust.io
resources:
Expand Down Expand Up @@ -127,6 +142,12 @@ rules:
- patch
- update
- watch
- apiGroups:
- endpoint.dns.appthrust.io
resources:
- endpointrecordsets/finalizers
verbs:
- update
- apiGroups:
- endpoint.dns.appthrust.io
resources:
Expand Down
4 changes: 2 additions & 2 deletions deploy/charts/dns-api/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ apiVersion: v2
name: dns-api
description: Kubernetes DNS API controller for Route 53 and Cloudflare
type: application
version: 0.2.5
appVersion: "0.2.5"
version: 0.2.6
appVersion: "0.2.6"
kubeVersion: ">=1.22.0-0"
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ spec:
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
generatedRecordSetCount:
description: |-
GeneratedRecordSetCount is the number of generated RecordSets that are
represented by this status.
format: int32
type: integer
hostnameCount:
description: HostnameCount is the number of hostname status entries.
format: int32
Expand Down Expand Up @@ -357,9 +363,21 @@ spec:
rule: self.type != 'AAAA' || has(self.aaaa) || has(self.options)
- message: cname is required when type is CNAME
rule: self.type != 'CNAME' || has(self.cname)
generation:
description: |-
Generation is the current metadata generation of the generated RecordSet.
It is zero until the generated RecordSet has been observed.
format: int64
type: integer
name:
description: Name is the zone-relative DNS owner name.
type: string
observedGeneration:
description: |-
ObservedGeneration is the generated RecordSet generation reflected by its
mirrored status conditions.
format: int64
type: integer
ref:
description: Ref points to the generated Core RecordSet.
properties:
Expand Down
88 changes: 88 additions & 0 deletions deploy/charts/dns-api/rbac_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package dnsapichart

import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)

// The dns-api chart owns only its controller identity. Cloud's central
// allocation writer is authorized by Cloud deployment RBAC; tenant identities
// must never gain EndpointRecordSet create through this upstream chart.
func TestEndpointRecordSetRBACBindsOnlyControllerServiceAccount(t *testing.T) {
rendered := renderChart(t,
"--set", "serviceAccount.name=dns-api-central-controller",
"--set", "gatewayEndpoint.enabled=false",
"--set", "serviceEndpoint.enabled=false",
)
role := renderedDocument(t, rendered, "rbac.authorization.k8s.io/v1", "ClusterRole")
endpointRule := ruleForAPIGroup(t, role, "endpoint.dns.appthrust.io")
for _, required := range []string{
"- endpoint.dns.appthrust.io",
"- endpointrecordsets",
"- create",
} {
if !strings.Contains(endpointRule, required) {
t.Fatalf("controller EndpointRecordSet rule is missing %q:\n%s", required, endpointRule)
}
}
binding := renderedDocument(t, rendered, "rbac.authorization.k8s.io/v1", "ClusterRoleBinding")
for _, required := range []string{
"kind: ServiceAccount",
"name: dns-api-central-controller",
"namespace: appthrust-dns",
} {
if !strings.Contains(binding, required) {
t.Fatalf("controller binding is missing %q:\n%s", required, binding)
}
}
for _, forbidden := range []string{
"system:authenticated",
"system:serviceaccounts",
"tenant",
"project",
} {
if strings.Contains(binding, forbidden) {
t.Fatalf("controller binding grants an ambient tenant subject %q:\n%s", forbidden, binding)
}
}
}

func ruleForAPIGroup(t *testing.T, role, apiGroup string) string {
t.Helper()
start := strings.Index(role, " - apiGroups:\n - "+apiGroup+"\n")
if start < 0 {
t.Fatalf("ClusterRole rule for %s was not found:\n%s", apiGroup, role)
}
remainder := role[start+1:]
if next := strings.Index(remainder, "\n - apiGroups:\n"); next >= 0 {
return role[start : start+1+next]
}
return role[start:]
}

func renderChart(t *testing.T, extra ...string) string {
t.Helper()
_, file, _, _ := runtime.Caller(0)
args := []string{"template", "dns-api", filepath.Dir(file), "--namespace", "appthrust-dns"}
args = append(args, extra...)
output, err := exec.Command("helm", args...).CombinedOutput()
if err != nil {
t.Fatalf("helm template failed: %v\n%s", err, output)
}
return string(output)
}

func renderedDocument(t *testing.T, rendered, apiVersion, kind string) string {
t.Helper()
for _, document := range strings.Split(rendered, "\n---") {
if strings.Contains(document, "apiVersion: "+apiVersion+"\n") &&
strings.Contains(document, "kind: "+kind+"\n") {
return document
}
}
t.Fatalf("rendered %s %s was not found:\n%s", apiVersion, kind, rendered)
return ""
}
2 changes: 2 additions & 0 deletions deploy/charts/dns-api/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ spec:
args:
- --leader-elect={{ .Values.leaderElection.enabled }}
- --endpoint-recordset-namespace={{ .Values.endpoint.recordSetNamespace }}
- --gateway-endpoint-controller-enabled={{ .Values.gatewayEndpoint.enabled }}
- --service-endpoint-controller-enabled={{ .Values.serviceEndpoint.enabled }}
{{- range .Values.extraArgs }}
- {{ . | quote }}
{{- end }}
Expand Down
14 changes: 13 additions & 1 deletion deploy/charts/dns-api/templates/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,28 @@ rules:
- ""
resources:
- namespaces
- services
- secrets
verbs:
- get
- list
- watch
{{- if .Values.serviceEndpoint.enabled }}
- apiGroups:
- ""
resources:
- services
verbs:
- get
- list
- watch
- update
- apiGroups:
- ""
resources:
- services/finalizers
verbs:
- update
{{- end }}
- apiGroups:
- cloudflare.dns.appthrust.io
resources:
Expand Down Expand Up @@ -135,6 +139,12 @@ rules:
- patch
- update
- watch
- apiGroups:
- endpoint.dns.appthrust.io
resources:
- endpointrecordsets/finalizers
verbs:
- update
- apiGroups:
- endpoint.dns.appthrust.io
resources:
Expand All @@ -149,6 +159,7 @@ rules:
- endpointrecordsetconversions
verbs:
- create
{{- if .Values.gatewayEndpoint.enabled }}
- apiGroups:
- gateway.networking.k8s.io
resources:
Expand All @@ -172,6 +183,7 @@ rules:
- httproutes/finalizers
verbs:
- update
{{- end }}
- apiGroups:
- route53.dns.appthrust.io
resources:
Expand Down
6 changes: 6 additions & 0 deletions deploy/charts/dns-api/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ leaderElection:
endpoint:
recordSetNamespace: dns-api-system

gatewayEndpoint:
enabled: true

serviceEndpoint:
enabled: true

metrics:
port: 8080

Expand Down
9 changes: 9 additions & 0 deletions docs/design/core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ For cross-namespace access, `records` is required. When a shared zone is exposed

`allowedRecordSets` is cross-namespace policy only. If a `RecordSet` references a `Zone` in the same namespace, `allowedRecordSets` is not evaluated. Same namespace is treated as one trust boundary. This allows application engineers to own a custom-domain `Zone` and manage `RecordSet` resources in the same namespace. Shared zones are placed in a platform namespace and limited with `allowedRecordSets`.

For a shared parent zone, the platform root-record owner and generated endpoint
writer must use different namespaces. A namespace-label selector, a full-match
record-name pattern such as one Organization label or `*.<organization>`, and
`types: [A, AAAA]` can then grant only Organization endpoint aliases. Parent
apex records, CAA, TXT, delegated NS, and deeper names remain outside that
grant; provider-owned SOA is not a tenant `RecordSet` type. Co-locating the
endpoint writer with the `Zone` would intentionally bypass this policy and is
therefore not a valid shared-zone topology.

Admission validates only the object-local shape of `allowedRecordSets`: required fields, selector syntax, record name pattern syntax, and record type values. It does not reject a `RecordSet` create/update because the current `Zone`, `RecordSet` namespace labels, record name, or record type are outside the policy. The Core ZoneUnit Controller evaluates the saved objects and sets `RecordSet.status.conditions[Accepted]` to `False`, reason `NotAllowedByZone`, when a cross-namespace `RecordSet` is not allowed by its referenced `Zone`.

When updating `Zone.spec.allowedRecordSets`, admission does not list existing `RecordSet` resources and does not reject policy shrink. If the new policy makes existing cross-namespace `RecordSet` resources disallowed, the Core ZoneUnit Controller re-evaluates them and returns `Accepted=False`, reason `NotAllowedByZone`. Same-namespace `RecordSet` resources are not affected by `allowedRecordSets`.
Expand Down
Loading
Loading