Skip to content

Commit 9c131ee

Browse files
authored
Merge pull request #655 from pdettori/feat/static-inject-plugin
Feat: add static-inject AuthBridge plugin for static credential injection
2 parents d17993f + adf5d2a commit 9c131ee

6 files changed

Lines changed: 883 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package staticinject
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
11+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
12+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
13+
)
14+
15+
// Source values for staticInjectConfig.Source.
16+
const (
17+
sourceSecretDir = "secret_dir"
18+
sourceMappings = "mappings"
19+
)
20+
21+
// KeyBy values for staticInjectConfig.KeyBy.
22+
const (
23+
keyByHost = "host"
24+
keyByStatic = "static"
25+
)
26+
27+
// staticInjectConfig is the plugin's local config schema. Named with a
28+
// package-specific prefix (rather than the repo's common bare `config`)
29+
// because this file also imports the shared authlib/config package under
30+
// its own name.
31+
//
32+
// Field tags drive both runtime decoding (json) and operator-facing
33+
// schema introspection (description / required / default / enum). See
34+
// pipeline/schema.go for the consumer contract.
35+
type staticInjectConfig struct {
36+
// Source selects where credential values come from: "secret_dir"
37+
// reads a file per key from SecretDir; "mappings" uses the inline
38+
// Mappings map (tests/dev only — do not put real secrets in YAML).
39+
Source string `json:"source" required:"true" description:"Credential source." enum:"secret_dir,mappings"`
40+
41+
// SecretDir is the directory containing one file per credential
42+
// key, used when source=secret_dir.
43+
SecretDir string `json:"secret_dir" description:"Directory of per-key credential files; used when source=secret_dir."`
44+
45+
// Mappings is an inline key->value credential map, used when
46+
// source=mappings. Tests/dev only.
47+
Mappings map[string]string `json:"mappings" description:"Inline key to credential map; used when source=mappings (tests/dev only)."`
48+
49+
// KeyBy selects how the resolver key is derived: "host" (default)
50+
// uses the outbound request's destination host; "static" always
51+
// uses the configured Key.
52+
KeyBy string `json:"key_by" description:"How to derive the resolver key." default:"host" enum:"host,static"`
53+
54+
// Key is the single lookup key used when key_by=static.
55+
Key string `json:"key" description:"Lookup key used when key_by=static."`
56+
57+
// Placeholder, when set, requires the inbound bearer to equal this
58+
// exact string before injection proceeds. Enforces that the
59+
// workload never presents (and therefore never holds) a real
60+
// credential — only the agreed-upon placeholder.
61+
Placeholder string `json:"placeholder" description:"When set, only inject if the inbound bearer equals this exact placeholder string."`
62+
63+
// InjectHeader is the request header the resolved credential is written to.
64+
// Default "Authorization" (written as "Bearer <value>", preserving legacy
65+
// behavior). Any other value (e.g. "x-api-key") writes the RAW credential
66+
// value and removes the inbound Authorization header so a stale placeholder
67+
// bearer never reaches the backend.
68+
InjectHeader string `json:"inject_header" description:"Header to inject the credential into. Default Authorization (Bearer scheme); any other value writes the raw value and drops Authorization." default:"Authorization"`
69+
}
70+
71+
func (c *staticInjectConfig) applyDefaults() {
72+
if c.KeyBy == "" {
73+
c.KeyBy = keyByHost
74+
}
75+
if c.InjectHeader == "" {
76+
c.InjectHeader = "Authorization"
77+
}
78+
}
79+
80+
func (c *staticInjectConfig) validate() error {
81+
switch c.Source {
82+
case sourceSecretDir:
83+
if c.SecretDir == "" {
84+
return fmt.Errorf("secret_dir is required when source is %q", sourceSecretDir)
85+
}
86+
case sourceMappings:
87+
if len(c.Mappings) == 0 {
88+
return fmt.Errorf("mappings is required when source is %q", sourceMappings)
89+
}
90+
default:
91+
return fmt.Errorf("source must be %q or %q, got %q", sourceSecretDir, sourceMappings, c.Source)
92+
}
93+
94+
switch c.KeyBy {
95+
case keyByHost:
96+
case keyByStatic:
97+
if c.Key == "" {
98+
return fmt.Errorf("key is required when key_by is %q", keyByStatic)
99+
}
100+
default:
101+
return fmt.Errorf("key_by must be %q or %q, got %q", keyByHost, keyByStatic, c.KeyBy)
102+
}
103+
return nil
104+
}
105+
106+
// buildResolver constructs the Resolver implied by c.Source. Callers must
107+
// have already validated c.
108+
func buildResolver(c staticInjectConfig) Resolver {
109+
switch c.Source {
110+
case sourceSecretDir:
111+
return FileResolver{Dir: c.SecretDir}
112+
case sourceMappings:
113+
return MapResolver(c.Mappings)
114+
default:
115+
// Unreachable after validate(), but return a resolver that
116+
// always fails closed rather than a nil interface that would
117+
// panic on use.
118+
return MapResolver(nil)
119+
}
120+
}
121+
122+
// StaticInject is an outbound plugin that swaps a placeholder credential on
123+
// the Authorization header for a real static credential resolved from a
124+
// configured source (a secret-mounted file or, for tests/dev, an inline
125+
// map). Built once via Configure; the zero value is a deny-everything
126+
// plugin (fail-closed) until Configure succeeds.
127+
type StaticInject struct {
128+
cfg staticInjectConfig
129+
resolver Resolver
130+
}
131+
132+
// New constructs an unconfigured plugin. Configure must be called before
133+
// the pipeline accepts traffic.
134+
func New() *StaticInject { return &StaticInject{} }
135+
136+
func init() {
137+
plugins.RegisterPlugin("static-inject", func() pipeline.Plugin { return New() })
138+
}
139+
140+
func (p *StaticInject) Name() string { return "static-inject" }
141+
142+
func (p *StaticInject) Capabilities() pipeline.PluginCapabilities {
143+
return pipeline.PluginCapabilities{
144+
Description: "Swaps a placeholder credential for a real static credential on outbound requests.",
145+
}
146+
}
147+
148+
// ConfigSchema implements pipeline.SchemaProvider; surfaces field metadata
149+
// to abctl edit templates and other config-aware tooling.
150+
func (p *StaticInject) ConfigSchema() []pipeline.FieldSchema {
151+
return pipeline.SchemaOf(staticInjectConfig{})
152+
}
153+
154+
func (p *StaticInject) Configure(raw json.RawMessage) error {
155+
var c staticInjectConfig
156+
if len(raw) > 0 {
157+
dec := json.NewDecoder(bytes.NewReader(raw))
158+
dec.DisallowUnknownFields()
159+
if err := dec.Decode(&c); err != nil {
160+
return fmt.Errorf("static-inject config: %w", err)
161+
}
162+
}
163+
c.applyDefaults()
164+
if err := c.validate(); err != nil {
165+
return fmt.Errorf("static-inject config: %w", err)
166+
}
167+
168+
resolver := buildResolver(c)
169+
170+
// Commit cfg+resolver to the struct only after all validation and
171+
// construction succeeds — a failed Configure leaves the plugin in
172+
// its zero deny-state.
173+
p.cfg = c
174+
p.resolver = resolver
175+
176+
return nil
177+
}
178+
179+
// OnRequest implements the fail-closed injection sequence: missing/mismatched
180+
// placeholder, unresolved key, or an unsafe resolved value all deny with 401
181+
// and leave the Authorization header unchanged. Only a fully successful
182+
// resolution swaps the header.
183+
func (p *StaticInject) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action {
184+
if p.resolver == nil {
185+
return pipeline.DenyStatus(401, "static-inject.unconfigured", "static-inject plugin not configured")
186+
}
187+
188+
bearer := auth.ExtractBearer(pctx.Headers.Get("Authorization"))
189+
if bearer == "" {
190+
return pipeline.DenyStatus(401, "static-inject.missing-auth", "missing bearer token on outbound request")
191+
}
192+
193+
if p.cfg.Placeholder != "" && bearer != p.cfg.Placeholder {
194+
return pipeline.DenyStatus(401, "static-inject.placeholder-mismatch", "workload did not present the configured placeholder")
195+
}
196+
197+
var key string
198+
switch p.cfg.KeyBy {
199+
case keyByStatic:
200+
key = p.cfg.Key
201+
default: // keyByHost
202+
key = pctx.Host
203+
}
204+
205+
value, ok := p.resolver.Resolve(ctx, key)
206+
// Fail closed on an empty credential as well: ReadCredentialFile trims a
207+
// whitespace-only secret file to "" and returns ok, and an inline mapping
208+
// may hold "". Without this guard either would forward an empty
209+
// "Bearer " / raw header instead of denying.
210+
if !ok || value == "" {
211+
return pipeline.DenyStatus(401, "static-inject.unresolved-key", "no credential available for the resolved key")
212+
}
213+
214+
target := p.cfg.InjectHeader
215+
var headerVal string
216+
if strings.EqualFold(target, "Authorization") {
217+
headerVal = "Bearer " + value
218+
} else {
219+
headerVal = value // raw credential, e.g. x-api-key
220+
}
221+
if !SafeSetHeader(pctx.Headers, target, headerVal) {
222+
return pipeline.DenyStatus(401, "static-inject.unsafe-value", "resolved credential value is unsafe to set as a header")
223+
}
224+
// When injecting into a non-Authorization header, drop the inbound
225+
// Authorization so the placeholder bearer never reaches the backend
226+
// (some backends reject a request carrying both a valid key and a
227+
// bogus Authorization).
228+
if !strings.EqualFold(target, "Authorization") {
229+
pctx.Headers.Del("Authorization")
230+
}
231+
232+
return pipeline.Action{Type: pipeline.Continue}
233+
}
234+
235+
func (p *StaticInject) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action {
236+
return pipeline.Action{Type: pipeline.Continue}
237+
}
238+
239+
// Compile-time interface checks.
240+
var (
241+
_ pipeline.Plugin = (*StaticInject)(nil)
242+
_ pipeline.Configurable = (*StaticInject)(nil)
243+
_ pipeline.SchemaProvider = (*StaticInject)(nil)
244+
)

0 commit comments

Comments
 (0)