Skip to content

Commit 88bda5e

Browse files
committed
fix(authz): support namespaces filtering
Signed-off-by: Roman Dmytrenko <[email protected]>
1 parent 1489fe1 commit 88bda5e

File tree

11 files changed

+377
-59
lines changed

11 files changed

+377
-59
lines changed

internal/server/authz/authz.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ package authz
33
import "context"
44

55
type Verifier interface {
6+
// IsAllowed returns whether the user is allowed to access the resource
67
IsAllowed(ctx context.Context, input map[string]any) (bool, error)
8+
// Namespaces returns the list of namespaces the user has access to
9+
Namespaces(ctx context.Context, input map[string]any) ([]string, error)
10+
// Shutdown is called when the server is shutting down
711
Shutdown(ctx context.Context) error
812
}
13+
14+
type contextKey string
15+
16+
const ViewableNamespacesKey contextKey = "viewable_namespaces"

internal/server/authz/engine/bundle/engine.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package bundle
22

33
import (
44
"context"
5+
"fmt"
56
"os"
67
"strings"
78

@@ -75,7 +76,6 @@ func (e *Engine) IsAllowed(ctx context.Context, input map[string]interface{}) (b
7576
Path: "flipt/authz/v1/allow",
7677
Input: input,
7778
})
78-
7979
if err != nil {
8080
return false, err
8181
}
@@ -84,6 +84,25 @@ func (e *Engine) IsAllowed(ctx context.Context, input map[string]interface{}) (b
8484
return allow, nil
8585
}
8686

87+
func (e *Engine) Namespaces(ctx context.Context, input map[string]interface{}) ([]string, error) {
88+
dec, err := e.opa.Decision(ctx, sdk.DecisionOptions{
89+
Path: "flipt/authz/v1/viewable_namespaces",
90+
Input: input,
91+
})
92+
if err != nil {
93+
return nil, err
94+
}
95+
values, ok := dec.Result.([]any)
96+
if !ok {
97+
return nil, fmt.Errorf("unexpected result type: %T", values)
98+
}
99+
namespaces := make([]string, len(values))
100+
for i, ns := range values {
101+
namespaces[i] = fmt.Sprintf("%s", ns)
102+
}
103+
return namespaces, nil
104+
}
105+
87106
func (e *Engine) Shutdown(ctx context.Context) error {
88107
e.opa.Stop(ctx)
89108
for _, cleanup := range e.cleanupFuncs {

internal/server/authz/engine/bundle/engine_test.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func TestEngine_IsAllowed(t *testing.T) {
6464
logger: zaptest.NewLogger(t),
6565
}
6666

67-
var tests = []struct {
67+
tests := []struct {
6868
name string
6969
input string
7070
expected bool
@@ -246,5 +246,78 @@ func TestEngine_IsAllowed(t *testing.T) {
246246
})
247247
}
248248

249+
t.Run("viewable namespaces without definition", func(t *testing.T) {
250+
namespaces, err := engine.Namespaces(ctx, map[string]any{})
251+
require.Error(t, err)
252+
require.Nil(t, namespaces)
253+
})
254+
249255
assert.NoError(t, engine.Shutdown(ctx))
250256
}
257+
258+
func TestViewableNamespaces(t *testing.T) {
259+
ctx := context.Background()
260+
261+
policy, err := os.ReadFile("../testdata/viewable_namespaces.rego")
262+
require.NoError(t, err)
263+
264+
data, err := os.ReadFile("../testdata/viewable_namespaces.json")
265+
require.NoError(t, err)
266+
267+
var (
268+
server = sdktest.MustNewServer(
269+
sdktest.MockBundle("/bundles/bundle.tar.gz", map[string]string{
270+
"main.rego": string(policy),
271+
"data.json": string(data),
272+
}),
273+
)
274+
config = fmt.Sprintf(`{
275+
"services": {
276+
"test": {
277+
"url": %q
278+
}
279+
},
280+
"bundles": {
281+
"test": {
282+
"resource": "/bundles/bundle.tar.gz"
283+
}
284+
},
285+
}`, server.URL())
286+
)
287+
288+
t.Cleanup(server.Stop)
289+
290+
opa, err := sdk.New(ctx, sdk.Options{
291+
Config: strings.NewReader(config),
292+
Store: inmem.New(),
293+
Logger: ozap.Wrap(zaptest.NewLogger(t), &zap.AtomicLevel{}),
294+
})
295+
296+
require.NoError(t, err)
297+
assert.NotNil(t, opa)
298+
299+
engine := &Engine{
300+
opa: opa,
301+
logger: zaptest.NewLogger(t),
302+
}
303+
t.Cleanup(func() {
304+
assert.NoError(t, engine.Shutdown(ctx))
305+
})
306+
307+
tt := []struct {
308+
name string
309+
roles []string
310+
namespaces []string
311+
}{
312+
{"empty", []string{}, []string{}},
313+
{"devs", []string{"devs"}, []string{"local", "staging"}},
314+
{"devsops", []string{"devs", "ops"}, []string{"local", "production", "staging"}},
315+
}
316+
for _, tt := range tt {
317+
t.Run(tt.name, func(t *testing.T) {
318+
namespaces, err := engine.Namespaces(ctx, map[string]any{"roles": tt.roles})
319+
require.NoError(t, err)
320+
require.Equal(t, tt.namespaces, namespaces)
321+
})
322+
}
323+
}

internal/server/authz/engine/rego/engine.go

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ type DataSource CachedSource[map[string]any]
3636
type Engine struct {
3737
logger *zap.Logger
3838

39-
mu sync.RWMutex
40-
query rego.PreparedEvalQuery
41-
store storage.Store
39+
mu sync.RWMutex
40+
queryAllow rego.PreparedEvalQuery
41+
queryNamespaces rego.PreparedEvalQuery
42+
store storage.Store
4243

4344
policySource PolicySource
4445
policyHash source.Hash
@@ -144,7 +145,7 @@ func (e *Engine) IsAllowed(ctx context.Context, input map[string]interface{}) (b
144145
defer e.mu.RUnlock()
145146

146147
e.logger.Debug("evaluating policy", zap.Any("input", input))
147-
results, err := e.query.Eval(ctx, rego.EvalInput(input))
148+
results, err := e.queryAllow.Eval(ctx, rego.EvalInput(input))
148149
if err != nil {
149150
return false, err
150151
}
@@ -160,6 +161,25 @@ func (e *Engine) Shutdown(_ context.Context) error {
160161
return nil
161162
}
162163

164+
func (e *Engine) Namespaces(ctx context.Context, input map[string]any) ([]string, error) {
165+
results, err := e.queryNamespaces.Eval(ctx, rego.EvalInput(input))
166+
if err != nil {
167+
return nil, err
168+
}
169+
if len(results) == 0 {
170+
return nil, errors.New("no results found")
171+
}
172+
values, ok := results[0].Expressions[0].Value.([]any)
173+
if !ok {
174+
return nil, fmt.Errorf("unexpected result type: %T", results[0].Expressions[0].Value)
175+
}
176+
namespaces := make([]string, len(values))
177+
for i, ns := range values {
178+
namespaces[i] = fmt.Sprintf("%s", ns)
179+
}
180+
return namespaces, nil
181+
}
182+
163183
func poll(ctx context.Context, d time.Duration, fn func()) {
164184
ticker := time.NewTicker(d)
165185
for {
@@ -186,15 +206,29 @@ func (e *Engine) updatePolicy(ctx context.Context) error {
186206
return fmt.Errorf("getting policy definition: %w", err)
187207
}
188208

209+
m := rego.Module("policy.rego", string(policy))
210+
s := rego.Store(e.store)
211+
189212
r := rego.New(
190213
rego.Query("data.flipt.authz.v1.allow"),
191-
rego.Module("policy.rego", string(policy)),
192-
rego.Store(e.store),
214+
m,
215+
s,
216+
)
217+
218+
queryAllow, err := r.PrepareForEval(ctx)
219+
if err != nil {
220+
return fmt.Errorf("preparing policy allow: %w", err)
221+
}
222+
223+
r = rego.New(
224+
rego.Query("data.flipt.authz.v1.viewable_namespaces"),
225+
m,
226+
s,
193227
)
194228

195-
query, err := r.PrepareForEval(ctx)
229+
queryNamespaces, err := r.PrepareForEval(ctx)
196230
if err != nil {
197-
return fmt.Errorf("preparing policy: %w", err)
231+
return fmt.Errorf("preparing policy namespaces: %w", err)
198232
}
199233

200234
e.mu.Lock()
@@ -204,7 +238,8 @@ func (e *Engine) updatePolicy(ctx context.Context) error {
204238
return nil
205239
}
206240
e.policyHash = hash
207-
e.query = query
241+
e.queryAllow = queryAllow
242+
e.queryNamespaces = queryNamespaces
208243

209244
return nil
210245
}

internal/server/authz/engine/rego/engine_test.go

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99
"time"
1010

11+
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
"go.flipt.io/flipt/internal/server/authz/engine/rego/source"
1314
authrpc "go.flipt.io/flipt/rpc/flipt/auth"
@@ -29,7 +30,18 @@ func TestEngine_NewEngine(t *testing.T) {
2930
}
3031

3132
func TestEngine_IsAllowed(t *testing.T) {
32-
var tests = []struct {
33+
policy, err := os.ReadFile("../testdata/rbac.rego")
34+
require.NoError(t, err)
35+
36+
data, err := os.ReadFile("../testdata/rbac.json")
37+
require.NoError(t, err)
38+
39+
ctx, cancel := context.WithCancel(context.Background())
40+
t.Cleanup(cancel)
41+
engine, err := newEngine(ctx, zaptest.NewLogger(t), withPolicySource(policySource(string(policy))), withDataSource(dataSource(string(data)), 5*time.Second))
42+
require.NoError(t, err)
43+
44+
tests := []struct {
3345
name string
3446
input string
3547
expected bool
@@ -200,17 +212,6 @@ func TestEngine_IsAllowed(t *testing.T) {
200212

201213
for _, tt := range tests {
202214
t.Run(tt.name, func(t *testing.T) {
203-
policy, err := os.ReadFile("../testdata/rbac.rego")
204-
require.NoError(t, err)
205-
206-
data, err := os.ReadFile("../testdata/rbac.json")
207-
require.NoError(t, err)
208-
209-
ctx, cancel := context.WithCancel(context.Background())
210-
t.Cleanup(cancel)
211-
engine, err := newEngine(ctx, zaptest.NewLogger(t), withPolicySource(policySource(string(policy))), withDataSource(dataSource(string(data)), 5*time.Second))
212-
require.NoError(t, err)
213-
214215
var input map[string]interface{}
215216

216217
err = json.Unmarshal([]byte(tt.input), &input)
@@ -221,10 +222,18 @@ func TestEngine_IsAllowed(t *testing.T) {
221222
require.Equal(t, tt.expected, allowed)
222223
})
223224
}
225+
226+
t.Run("viewable namespaces without definition", func(t *testing.T) {
227+
ctx, cancel := context.WithCancel(context.Background())
228+
t.Cleanup(cancel)
229+
namespaces, err := engine.Namespaces(ctx, map[string]any{})
230+
require.Error(t, err)
231+
require.Nil(t, namespaces)
232+
})
224233
}
225234

226235
func TestEngine_IsAuthMethod(t *testing.T) {
227-
var tests = []struct {
236+
tests := []struct {
228237
name string
229238
input authrpc.Method
230239
expected bool
@@ -269,6 +278,40 @@ func TestEngine_IsAuthMethod(t *testing.T) {
269278
}
270279
}
271280

281+
func TestViewableNamespaces(t *testing.T) {
282+
policy, err := os.ReadFile("../testdata/viewable_namespaces.rego")
283+
require.NoError(t, err)
284+
285+
data, err := os.ReadFile("../testdata/viewable_namespaces.json")
286+
require.NoError(t, err)
287+
288+
ctx, cancel := context.WithCancel(context.Background())
289+
t.Cleanup(cancel)
290+
engine, err := newEngine(ctx, zaptest.NewLogger(t), withPolicySource(policySource(string(policy))), withDataSource(dataSource(string(data)), 5*time.Second))
291+
require.NoError(t, err)
292+
293+
t.Cleanup(func() {
294+
assert.NoError(t, engine.Shutdown(ctx))
295+
})
296+
297+
tt := []struct {
298+
name string
299+
roles []string
300+
namespaces []string
301+
}{
302+
{"empty", []string{}, []string{}},
303+
{"devs", []string{"devs"}, []string{"local", "staging"}},
304+
{"devsops", []string{"devs", "ops"}, []string{"local", "production", "staging"}},
305+
}
306+
for _, tt := range tt {
307+
t.Run(tt.name, func(t *testing.T) {
308+
namespaces, err := engine.Namespaces(ctx, map[string]any{"roles": tt.roles})
309+
require.NoError(t, err)
310+
require.Equal(t, tt.namespaces, namespaces)
311+
})
312+
}
313+
}
314+
272315
type policySource string
273316

274317
func (p policySource) Get(context.Context, source.Hash) ([]byte, source.Hash, error) {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"roles_to_namespaces": {
3+
"devs": ["local", "staging"],
4+
"ops": ["staging", "production"]
5+
}
6+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
package flipt.authz.v1
3+
4+
import rego.v1
5+
import data
6+
7+
viewable_namespaces contains namespace if {
8+
some role in input.roles
9+
some namespace in data.roles_to_namespaces[role]
10+
}
11+
12+
default allow := false
13+
14+
allow if {
15+
input.request.namespace in viewable_namespaces
16+
}

0 commit comments

Comments
 (0)