Skip to content

Commit e2db9e1

Browse files
committed
Add bundle service for serving OPA authorization policy bundles
Introduce the bundle-service component, an HTTP server that assembles and serves OPA-compatible authorization bundles to AuthBridge sidecar clients. Each bundle is tailored to the requesting client's identity and built from three tiers of AuthorizationPolicy CRs: global, namespace, and client-scoped. Key design decisions: - Decision logic lives in the global AuthorizationPolicy CR, not in code, giving platform engineers full control over tier combination semantics (override support, tier removal, AND/OR changes) - Bounded concurrency (semaphore + singleflight) protects the Kubernetes API server and etcd from thundering herd on cluster restart - Three-layer caching (ETag, bundle, policy) minimizes rebuild pressure - ETag-based conditional responses (304 Not Modified) reduce bandwidth Includes: - AuthorizationPolicy CRD types and conversion helpers - Bundle builder (tar.gz with OPA manifest) - Multi-tier policy cache with scope-aware invalidation - Kubernetes informer-based watcher with scope indexing - SPIFFE ID identity parsing - Deployment manifests, RBAC, NetworkPolicy, default global policy CR - Kind development script (hack/bundle-service-kind.sh) - Architecture and operational documentation - Comprehensive unit tests for all packages Signed-off-by: David Hadas <david.hadas@gmail.com>
1 parent dcb191d commit e2db9e1

41 files changed

Lines changed: 4092 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ The operator runs the following controllers and webhooks:
9595
| **NetworkPolicy Controller** | Creates permissive or restrictive NetworkPolicies based on signature verification status |
9696
| **MLflow Controller** | Auto-discovers MLflow instances, creates experiments per agent, injects tracking env vars and RBAC |
9797

98+
## Bundle Service
99+
100+
Kagenti includes a dedicated bundle service used by AuthBridge clients to fetch authorization bundles.
101+
102+
This service is deployed using the manifests in `kagenti-operator/config/bundleservice/` and is intended for SRE operational use.
103+
104+
Key facts:
105+
106+
- Deployment name: `bundle-service`
107+
- Namespace: `system`
108+
- Service type: `ClusterIP`
109+
- Port: `8080`
110+
- Health endpoints: `/healthz`, `/readyz`
111+
112+
Use `kagenti-operator/kagenti-operator/cmd/bundle-service/README.md` for SRE runbook guidance and operational details.
113+
98114
## Quick Start
99115

100116
### Prerequisites
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
apiVersion: apiextensions.k8s.io/v1
2+
kind: CustomResourceDefinition
3+
metadata:
4+
name: authorizationpolicies.agent.kagenti.dev
5+
spec:
6+
group: agent.kagenti.dev
7+
names:
8+
kind: AuthorizationPolicy
9+
listKind: AuthorizationPolicyList
10+
plural: authorizationpolicies
11+
singular: authorizationpolicy
12+
shortNames:
13+
- ap
14+
scope: Namespaced
15+
versions:
16+
- name: v1alpha1
17+
served: true
18+
storage: true
19+
subresources:
20+
status: {}
21+
additionalPrinterColumns:
22+
- name: Scope
23+
type: string
24+
jsonPath: .spec.scope
25+
- name: ClientID
26+
type: string
27+
jsonPath: .spec.clientID
28+
- name: Hash
29+
type: string
30+
jsonPath: .status.bundleHash
31+
priority: 1
32+
- name: Age
33+
type: date
34+
jsonPath: .metadata.creationTimestamp
35+
schema:
36+
openAPIV3Schema:
37+
type: object
38+
required:
39+
- spec
40+
properties:
41+
spec:
42+
type: object
43+
required:
44+
- scope
45+
- policies
46+
properties:
47+
scope:
48+
type: string
49+
enum:
50+
- global
51+
- namespace
52+
- client
53+
default: client
54+
clientID:
55+
type: string
56+
maxLength: 253
57+
pattern: "^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$"
58+
policies:
59+
type: array
60+
minItems: 1
61+
items:
62+
type: object
63+
required:
64+
- path
65+
- content
66+
properties:
67+
path:
68+
type: string
69+
minLength: 1
70+
pattern: "^[a-z0-9][a-z0-9/_.-]*\\.rego$"
71+
content:
72+
type: string
73+
minLength: 1
74+
x-kubernetes-validations:
75+
- rule: "self.scope == 'client' ? self.clientID != '' : true"
76+
message: "clientID is required when scope is 'client'"
77+
- rule: "self.scope != 'client' ? !has(self.clientID) || self.clientID == '' : true"
78+
message: "clientID must not be set when scope is 'global' or 'namespace'"
79+
status:
80+
type: object
81+
properties:
82+
bundleHash:
83+
type: string
84+
lastBuilt:
85+
type: string
86+
format: date-time
87+
conditions:
88+
type: array
89+
items:
90+
type: object
91+
required:
92+
- type
93+
- status
94+
properties:
95+
type:
96+
type: string
97+
status:
98+
type: string
99+
enum:
100+
- "True"
101+
- "False"
102+
- "Unknown"
103+
lastTransitionTime:
104+
type: string
105+
format: date-time
106+
reason:
107+
type: string
108+
message:
109+
type: string
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import (
20+
"fmt"
21+
22+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
23+
"k8s.io/apimachinery/pkg/runtime"
24+
)
25+
26+
func AuthorizationPolicyFromUnstructured(obj *unstructured.Unstructured) (*AuthorizationPolicy, error) {
27+
var ap AuthorizationPolicy
28+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &ap); err != nil {
29+
return nil, fmt.Errorf("converting from unstructured: %w", err)
30+
}
31+
return &ap, nil
32+
}
33+
34+
func AuthorizationPolicyToUnstructured(ap *AuthorizationPolicy) (*unstructured.Unstructured, error) {
35+
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(ap)
36+
if err != nil {
37+
return nil, fmt.Errorf("converting to unstructured: %w", err)
38+
}
39+
return &unstructured.Unstructured{Object: obj}, nil
40+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import (
20+
"testing"
21+
22+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
24+
)
25+
26+
func TestAuthorizationPolicyFromUnstructured(t *testing.T) {
27+
obj := &unstructured.Unstructured{
28+
Object: map[string]any{
29+
"apiVersion": "agent.kagenti.dev/v1alpha1",
30+
"kind": "AuthorizationPolicy",
31+
"metadata": map[string]any{
32+
"name": "test-client",
33+
"namespace": "default",
34+
},
35+
"spec": map[string]any{
36+
"scope": "client",
37+
"clientID": "test-client",
38+
"policies": []any{
39+
map[string]any{
40+
"path": "inbound/request.rego",
41+
"content": "package authbridge.client\ndefault allow := true\n",
42+
},
43+
},
44+
},
45+
},
46+
}
47+
48+
ap, err := AuthorizationPolicyFromUnstructured(obj)
49+
if err != nil {
50+
t.Fatalf("AuthorizationPolicyFromUnstructured failed: %v", err)
51+
}
52+
if ap.Spec.Scope != PolicyScopeClient {
53+
t.Fatalf("unexpected scope: %s", ap.Spec.Scope)
54+
}
55+
if ap.Spec.ClientID != "test-client" {
56+
t.Fatalf("unexpected clientID: %s", ap.Spec.ClientID)
57+
}
58+
if len(ap.Spec.Policies) != 1 {
59+
t.Fatalf("expected 1 policy, got %d", len(ap.Spec.Policies))
60+
}
61+
}
62+
63+
func TestAuthorizationPolicyFromUnstructured_Global(t *testing.T) {
64+
obj := &unstructured.Unstructured{
65+
Object: map[string]any{
66+
"apiVersion": "agent.kagenti.dev/v1alpha1",
67+
"kind": "AuthorizationPolicy",
68+
"metadata": map[string]any{
69+
"name": "global-policy",
70+
"namespace": "kagenti-system",
71+
},
72+
"spec": map[string]any{
73+
"scope": "global",
74+
"policies": []any{
75+
map[string]any{
76+
"path": "inbound/request.rego",
77+
"content": "package authbridge.global\ndefault allow := true\n",
78+
},
79+
},
80+
},
81+
},
82+
}
83+
84+
ap, err := AuthorizationPolicyFromUnstructured(obj)
85+
if err != nil {
86+
t.Fatalf("AuthorizationPolicyFromUnstructured failed: %v", err)
87+
}
88+
if ap.Spec.Scope != PolicyScopeGlobal {
89+
t.Fatalf("unexpected scope: %s", ap.Spec.Scope)
90+
}
91+
if ap.Spec.ClientID != "" {
92+
t.Fatalf("expected empty clientID for global, got: %s", ap.Spec.ClientID)
93+
}
94+
}
95+
96+
func TestAuthorizationPolicyRoundTrip(t *testing.T) {
97+
original := &AuthorizationPolicy{
98+
TypeMeta: metav1.TypeMeta{
99+
APIVersion: "agent.kagenti.dev/v1alpha1",
100+
Kind: "AuthorizationPolicy",
101+
},
102+
ObjectMeta: metav1.ObjectMeta{
103+
Name: "my-client",
104+
Namespace: "default",
105+
},
106+
Spec: AuthorizationPolicySpec{
107+
Scope: PolicyScopeClient,
108+
ClientID: "my-client",
109+
Policies: []PolicyEntry{
110+
{
111+
Path: "inbound/request.rego",
112+
Content: "package authbridge.client\ndefault allow := false\n",
113+
},
114+
{
115+
Path: "outbound/request.rego",
116+
Content: "package authbridge.client\ndefault allow := true\n",
117+
},
118+
},
119+
},
120+
}
121+
122+
obj, err := AuthorizationPolicyToUnstructured(original)
123+
if err != nil {
124+
t.Fatalf("AuthorizationPolicyToUnstructured failed: %v", err)
125+
}
126+
127+
roundTripped, err := AuthorizationPolicyFromUnstructured(obj)
128+
if err != nil {
129+
t.Fatalf("AuthorizationPolicyFromUnstructured failed: %v", err)
130+
}
131+
132+
if roundTripped.Spec.Scope != original.Spec.Scope {
133+
t.Fatalf("scope mismatch: %s vs %s", roundTripped.Spec.Scope, original.Spec.Scope)
134+
}
135+
if roundTripped.Spec.ClientID != original.Spec.ClientID {
136+
t.Fatalf("clientID mismatch: %s vs %s", roundTripped.Spec.ClientID, original.Spec.ClientID)
137+
}
138+
if len(roundTripped.Spec.Policies) != len(original.Spec.Policies) {
139+
t.Fatalf("policy count mismatch: %d vs %d", len(roundTripped.Spec.Policies), len(original.Spec.Policies))
140+
}
141+
}

0 commit comments

Comments
 (0)