Skip to content

Commit f1c5e76

Browse files
authored
Merge pull request #491 from davidhadas/remove_opa_plugin_dependency
Feat(opa): implement singleton OPA SDK to eliminate duplicate bundle …
2 parents b64d7fd + 3ada324 commit f1c5e76

3 files changed

Lines changed: 386 additions & 113 deletions

File tree

authbridge/authlib/plugins/opa/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ responses against the loaded policy using four fixed decision paths.
99
1. At startup the plugin reads the agent's client ID from `/shared/client-id.txt`
1010
(mounted by the kagenti-operator from a Keycloak-credentials Secret).
1111
2. It creates an embedded OPA engine via the OPA Go SDK and configures it to
12-
fetch `bundles/<agent-id>.tar.gz` from the bundle server.
12+
fetch `bundles?spiffe=<url-encoded-spiffe-id>` from the bundle server.
1313
3. The SDK downloads the bundle, activates the policy, and begins periodic
1414
polling for updates (respecting `ETag` / `If-None-Match` for lightweight
1515
304 responses).
@@ -415,9 +415,11 @@ The plugin interprets the decision as follows:
415415

416416
## Bundle layout
417417

418-
The bundle server must serve a standard OPA bundle at the path
419-
`bundles/<agent-id>.tar.gz`. A minimal bundle contains a single `.rego` file
420-
for the inbound request path:
418+
The bundle server must serve a standard OPA bundle at
419+
`bundles?spiffe=<url-encoded-spiffe-id>`. The SPIFFE ID is read from
420+
`/shared/client-id.txt` (or the `agent_id` config field), stripped of
421+
the `spiffe://` prefix, and URL-encoded. A minimal bundle contains a
422+
single `.rego` file for the inbound request path:
421423

422424
```
423425
bundles/my-agent.tar.gz

authbridge/authlib/plugins/opa/plugin.go

Lines changed: 182 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import (
1010
"net/http"
1111
"net/url"
1212
"strings"
13+
"sync"
1314
"sync/atomic"
15+
"time"
1416

1517
"github.com/open-policy-agent/opa/sdk"
18+
opalog "github.com/open-policy-agent/opa/v1/logging"
1619

1720
"github.com/kagenti/kagenti-extensions/authbridge/authlib/config"
1821
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
@@ -82,21 +85,100 @@ func (c *opaConfig) validate() error {
8285
return nil
8386
}
8487

88+
// slogBridge bridges OPA's logging.Logger interface to Go's slog, so that all
89+
// OPA SDK internal messages (bundle downloads, parse errors, activation) surface
90+
// in the authbridge log stream.
91+
type slogBridge struct {
92+
level opalog.Level
93+
fields map[string]interface{}
94+
}
95+
96+
func newSlogBridge() *slogBridge {
97+
return &slogBridge{level: opalog.Debug}
98+
}
99+
100+
func (b *slogBridge) attrs() []slog.Attr {
101+
attrs := make([]slog.Attr, 0, len(b.fields)+1)
102+
attrs = append(attrs, slog.String("component", "opa-sdk"))
103+
for k, v := range b.fields {
104+
attrs = append(attrs, slog.Any(k, v))
105+
}
106+
return attrs
107+
}
108+
109+
func (b *slogBridge) Debug(msg string, a ...interface{}) {
110+
if len(a) > 0 {
111+
msg = fmt.Sprintf(msg, a...)
112+
}
113+
slog.LogAttrs(context.Background(), slog.LevelDebug, msg, b.attrs()...)
114+
}
115+
116+
func (b *slogBridge) Info(msg string, a ...interface{}) {
117+
if len(a) > 0 {
118+
msg = fmt.Sprintf(msg, a...)
119+
}
120+
slog.LogAttrs(context.Background(), slog.LevelInfo, msg, b.attrs()...)
121+
}
122+
123+
func (b *slogBridge) Warn(msg string, a ...interface{}) {
124+
if len(a) > 0 {
125+
msg = fmt.Sprintf(msg, a...)
126+
}
127+
slog.LogAttrs(context.Background(), slog.LevelWarn, msg, b.attrs()...)
128+
}
129+
130+
func (b *slogBridge) Error(msg string, a ...interface{}) {
131+
if len(a) > 0 {
132+
msg = fmt.Sprintf(msg, a...)
133+
}
134+
slog.LogAttrs(context.Background(), slog.LevelError, msg, b.attrs()...)
135+
}
136+
137+
func (b *slogBridge) WithFields(fields map[string]interface{}) opalog.Logger {
138+
cp := &slogBridge{level: b.level, fields: make(map[string]interface{}, len(b.fields)+len(fields))}
139+
for k, v := range b.fields {
140+
cp.fields[k] = v
141+
}
142+
for k, v := range fields {
143+
cp.fields[k] = v
144+
}
145+
return cp
146+
}
147+
148+
func (b *slogBridge) SetLevel(level opalog.Level) { b.level = level }
149+
func (b *slogBridge) GetLevel() opalog.Level { return b.level }
150+
85151
// decider abstracts OPA decision-making for testability.
86152
type decider interface {
87153
Decision(ctx context.Context, options sdk.DecisionOptions) (*sdk.DecisionResult, error)
88154
Stop(ctx context.Context)
89155
}
90156

157+
// sharedSDK holds the process-wide singleton OPA SDK instance. Both inbound
158+
// and outbound plugin instances share one SDK (one bundle download, one
159+
// memory footprint) because the bundle is per-agent, not per-direction.
160+
type sharedSDK struct {
161+
decider decider
162+
ready atomic.Bool
163+
done chan struct{}
164+
bundleURL string
165+
agentID string
166+
refCount int
167+
}
168+
169+
var (
170+
singletonMu sync.Mutex
171+
singleton *sharedSDK
172+
)
173+
91174
// OPA evaluates requests against OPA bundles downloaded from a Kagenti
92175
// Bundle Server. The bundle resource path is derived from the agent's
93176
// identity (/shared/client-id.txt).
94177
type OPA struct {
95178
cfg opaConfig
96179
inc includeSet
97180
agentID string
98-
decider atomic.Pointer[decider]
99-
ready atomic.Bool
181+
shared atomic.Pointer[sharedSDK]
100182
bgCancel atomic.Pointer[context.CancelFunc]
101183
}
102184

@@ -108,8 +190,6 @@ func (p *OPA) Name() string { return "opa" }
108190

109191
func (p *OPA) Capabilities() pipeline.PluginCapabilities {
110192
return pipeline.PluginCapabilities{
111-
Requires: []string{"jwt-validation"},
112-
RequiresAny: []string{"a2a-parser", "mcp-parser", "inference-parser"},
113193
Description: "OPA policy enforcement for inbound and outbound requests.",
114194
}
115195
}
@@ -152,7 +232,12 @@ func (p *OPA) Configure(raw json.RawMessage) error {
152232

153233
func (p *OPA) Init(_ context.Context) error {
154234
if p.agentID != "" {
155-
return p.startOPA()
235+
slog.Info("opa: initializing with agent_id", "agent_id", p.agentID)
236+
if err := p.startOPA(); err != nil {
237+
slog.Error("opa: initialization failed", "error", err, "agent_id", p.agentID)
238+
return err
239+
}
240+
return nil
156241
}
157242
if p.cfg.AgentIDFile == "" {
158243
return errors.New("opa: no agent_id or agent_id_file configured")
@@ -179,24 +264,74 @@ func (p *OPA) Init(_ context.Context) error {
179264
}
180265

181266
func (p *OPA) startOPA() error {
267+
singletonMu.Lock()
268+
defer singletonMu.Unlock()
269+
270+
if singleton != nil {
271+
if singleton.bundleURL != p.cfg.BundleURL {
272+
slog.Warn("opa: redundant bundle_url for the opa sdk singleton - second config skipped",
273+
"skipped_bundle_url", p.cfg.BundleURL,
274+
"active_bundle_url", singleton.bundleURL)
275+
}
276+
singleton.refCount++
277+
p.shared.Store(singleton)
278+
slog.Info("opa: reusing shared OPA SDK singleton", "agent_id", singleton.agentID)
279+
return nil
280+
}
281+
182282
cfgBytes, agentID, err := p.buildOPAConfig()
183283
if err != nil {
284+
slog.Error("opa: failed to build OPA config", "error", err)
184285
return err
185286
}
287+
288+
slog.Info("opa: starting OPA SDK with config", "config", string(cfgBytes), "agent_id", agentID)
289+
186290
readyCh := make(chan struct{})
291+
292+
opaLogger := newSlogBridge()
293+
187294
opa, err := sdk.New(context.Background(), sdk.Options{
188-
Config: bytes.NewReader(cfgBytes),
189-
Ready: readyCh,
295+
Config: bytes.NewReader(cfgBytes),
296+
Ready: readyCh,
297+
Logger: opaLogger,
298+
ConsoleLogger: opaLogger,
299+
V1Compatible: true,
190300
})
191301
if err != nil {
302+
slog.Error("opa: failed to initialize OPA SDK", "error", err, "agent_id", agentID)
192303
return fmt.Errorf("opa sdk.New: %w", err)
193304
}
194-
var dec decider = opa
195-
p.decider.Store(&dec)
305+
306+
s := &sharedSDK{
307+
decider: opa,
308+
done: make(chan struct{}),
309+
bundleURL: p.cfg.BundleURL,
310+
agentID: agentID,
311+
refCount: 1,
312+
}
313+
singleton = s
314+
p.shared.Store(s)
315+
196316
go func() {
197-
<-readyCh
198-
p.ready.Store(true)
199-
slog.Info("opa: bundle loaded and policy activated", "agent_id", agentID)
317+
select {
318+
case <-readyCh:
319+
s.ready.Store(true)
320+
slog.Info("opa: bundle loaded and policy activated", "agent_id", agentID)
321+
return
322+
case <-s.done:
323+
return
324+
case <-time.After(30 * time.Second):
325+
slog.Warn("opa: bundle not yet available after 30s, will keep retrying",
326+
"agent_id", agentID,
327+
"bundle_url", p.cfg.BundleURL)
328+
}
329+
select {
330+
case <-readyCh:
331+
s.ready.Store(true)
332+
slog.Info("opa: bundle loaded and policy activated", "agent_id", agentID)
333+
case <-s.done:
334+
}
200335
}()
201336
return nil
202337
}
@@ -208,8 +343,12 @@ func (p *OPA) buildOPAConfig() ([]byte, string, error) {
208343
return nil, "", errors.New("agentID is empty")
209344
}
210345

211-
// Escape agentID to prevent path traversal and ensure safe URL path segment
212-
escapedAgentID := url.PathEscape(agentID)
346+
// Strip "spiffe://" prefix if present to get the SPIFFE ID for the query parameter
347+
spiffeID := strings.TrimPrefix(agentID, "spiffe://")
348+
349+
// URL-encode SPIFFE ID for query parameter
350+
// This is REQUIRED because SPIFFE IDs contain '/' which must be encoded as %2F
351+
escapedSPIFFEID := url.QueryEscape(spiffeID)
213352

214353
cfg := map[string]any{
215354
"services": map[string]any{
@@ -220,13 +359,19 @@ func (p *OPA) buildOPAConfig() ([]byte, string, error) {
220359
"bundles": map[string]any{
221360
"authz": map[string]any{
222361
"service": "kagenti",
223-
"resource": fmt.Sprintf("bundles/%s.tar.gz", escapedAgentID),
362+
"resource": fmt.Sprintf("bundles?spiffe=%s", escapedSPIFFEID),
224363
"polling": map[string]any{
225364
"min_delay_seconds": p.cfg.PollingMinDelay,
226365
"max_delay_seconds": p.cfg.PollingMaxDelay,
227366
},
228367
},
229368
},
369+
"decision_logs": map[string]any{
370+
"console": true,
371+
},
372+
"logging": map[string]any{
373+
"level": "info",
374+
},
230375
}
231376
data, _ := json.Marshal(cfg)
232377
return data, agentID, nil
@@ -236,14 +381,27 @@ func (p *OPA) Shutdown(ctx context.Context) error {
236381
if cancel := p.bgCancel.Swap(nil); cancel != nil {
237382
(*cancel)()
238383
}
239-
if dec := p.decider.Load(); dec != nil {
240-
(*dec).Stop(ctx)
384+
s := p.shared.Swap(nil)
385+
if s == nil {
386+
return nil
387+
}
388+
singletonMu.Lock()
389+
defer singletonMu.Unlock()
390+
s.refCount--
391+
if s.refCount <= 0 {
392+
close(s.done)
393+
s.decider.Stop(ctx)
394+
singleton = nil
241395
}
242396
return nil
243397
}
244398

245399
func (p *OPA) Ready() bool {
246-
return p.ready.Load()
400+
s := p.shared.Load()
401+
if s == nil {
402+
return false
403+
}
404+
return s.ready.Load()
247405
}
248406

249407
func (p *OPA) decisionPath(pctx *pipeline.Context, phase string) string {
@@ -260,8 +418,8 @@ func (p *OPA) decisionPath(pctx *pipeline.Context, phase string) string {
260418
}
261419

262420
func (p *OPA) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action {
263-
dec := p.decider.Load()
264-
if dec == nil || !p.ready.Load() {
421+
s := p.shared.Load()
422+
if s == nil || !s.ready.Load() {
265423
pctx.Record(pipeline.Invocation{
266424
Action: pipeline.ActionDeny,
267425
Reason: "opa_not_ready",
@@ -271,7 +429,7 @@ func (p *OPA) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Ac
271429

272430
path := p.decisionPath(pctx, "request")
273431
input := buildInput(pctx, p.inc)
274-
result, err := (*dec).Decision(ctx, sdk.DecisionOptions{
432+
result, err := s.decider.Decision(ctx, sdk.DecisionOptions{
275433
Path: path,
276434
Input: input,
277435
})
@@ -308,8 +466,8 @@ func (p *OPA) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Ac
308466
}
309467

310468
func (p *OPA) OnResponse(ctx context.Context, pctx *pipeline.Context) pipeline.Action {
311-
dec := p.decider.Load()
312-
if dec == nil || !p.ready.Load() {
469+
s := p.shared.Load()
470+
if s == nil || !s.ready.Load() {
313471
pctx.Record(pipeline.Invocation{
314472
Action: pipeline.ActionDeny,
315473
Reason: "opa_not_ready",
@@ -323,7 +481,7 @@ func (p *OPA) OnResponse(ctx context.Context, pctx *pipeline.Context) pipeline.A
323481
"status_code": pctx.StatusCode,
324482
"headers": flattenHeaders(pctx.ResponseHeaders),
325483
}
326-
result, err := (*dec).Decision(ctx, sdk.DecisionOptions{
484+
result, err := s.decider.Decision(ctx, sdk.DecisionOptions{
327485
Path: path,
328486
Input: input,
329487
})

0 commit comments

Comments
 (0)