Skip to content

Commit de911e4

Browse files
authored
extensions: add metadata support for the cedar-auth extension (#306)
Same change as #289 Signed-off-by: Ignasi Barrera <ignasi@tetrate.io>
1 parent 3765bc6 commit de911e4

7 files changed

Lines changed: 165 additions & 4 deletions

File tree

extensions/composer/cedar/manifest.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ longDescription: |
7878
"mtls": true,
7979
"tls_version": "TLSv1.3"
8080
},
81+
"dynamic_metadata": {
82+
"envoy.filters.http.jwt_authn": {
83+
"sub": "user-123",
84+
"iss": "https://issuer.example.com"
85+
}
86+
},
8187
"parsed_path": ["api", "v1", "resource"],
8288
"parsed_query": {"key": ["value"]}
8389
}
@@ -294,3 +300,15 @@ examples:
294300
"principal_id_header": "x-user-id",
295301
"fail_open": true
296302
}'
303+
- title: Include Envoy dynamic metadata in Cedar context
304+
description: |
305+
Include Envoy dynamic metadata in the Cedar context record under `context.dynamic_metadata`.
306+
This allows writing policies that make decisions based on dynamic metadata from other Envoy
307+
filters, such as JWT authentication claims.
308+
code: |
309+
boe run --extension cedar-auth --config '{
310+
"policy": {"file": "/tmp/policy.cedar"},
311+
"principal_type": "User",
312+
"principal_id_header": "x-user-id",
313+
"metadata_namespaces": ["envoy.filters.http.jwt_authn"]
314+
}'

extensions/composer/cedar/plugin.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ type cedarConfig struct {
4646
FailOpen bool `json:"fail_open"`
4747
// DryRun when true logs the decision but always allows the request.
4848
DryRun bool `json:"dry_run"`
49+
// MetadataNamespaces is an optional list of dynamic metadata namespaces to include in the Cedar
50+
// context record under the "dynamic_metadata" key.
51+
MetadataNamespaces []string `json:"metadata_namespaces"`
4952
}
5053

5154
// Metric tag values for authorization decisions.
@@ -274,11 +277,34 @@ func (c *cedarHttpFilter) buildContext(headers shared.HeaderMap) cedarlib.Record
274277
"mtls": cedarlib.Boolean(mtls),
275278
"tls_version": cedarlib.String(tlsVersion.ToUnsafeString()),
276279
}),
277-
"parsed_path": cedarlib.NewSet(pathValues...),
278-
"parsed_query": cedarlib.NewRecord(queryRecord),
280+
"dynamic_metadata": c.dynamicMetadataMap(),
281+
"parsed_path": cedarlib.NewSet(pathValues...),
282+
"parsed_query": cedarlib.NewRecord(queryRecord),
279283
})
280284
}
281285

286+
// dynamicMetadataMap extracts dynamic metadata from the filter handle and returns it as a
287+
// Cedar Record keyed by namespace, where each namespace value is a Record of key-value pairs.
288+
func (c *cedarHttpFilter) dynamicMetadataMap() cedarlib.Value {
289+
dm := cedarlib.RecordMap{}
290+
for _, ns := range c.config.MetadataNamespaces {
291+
nsMap := cedarlib.RecordMap{}
292+
keys := c.handle.GetMetadataKeys(shared.MetadataSourceTypeDynamic, ns)
293+
for _, key := range keys {
294+
keyStr := key.ToUnsafeString()
295+
if value, ok := c.handle.GetMetadataString(shared.MetadataSourceTypeDynamic, ns, keyStr); ok {
296+
nsMap[cedarlib.String(keyStr)] = cedarlib.String(value.ToUnsafeString())
297+
} else if numValue, ok := c.handle.GetMetadataNumber(shared.MetadataSourceTypeDynamic, ns, keyStr); ok {
298+
nsMap[cedarlib.String(keyStr)] = cedarlib.Long(int64(numValue))
299+
}
300+
}
301+
if len(nsMap) > 0 {
302+
dm[cedarlib.String(ns)] = cedarlib.NewRecord(nsMap)
303+
}
304+
}
305+
return cedarlib.NewRecord(dm)
306+
}
307+
282308
// parsePath splits the path into segments and parses query parameters into a map.
283309
func parsePath(fullPath string) ([]string, map[string][]string) {
284310
pathPart := fullPath

extensions/composer/cedar/plugin_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"path/filepath"
1212
"testing"
1313

14+
cedarlib "github.com/cedar-policy/cedar-go"
1415
"github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared"
1516
"github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared/fake"
1617
"github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared/mocks"
@@ -1514,3 +1515,104 @@ func TestFilterFactory_Create(t *testing.T) {
15141515
require.True(t, ok)
15151516
require.Equal(t, mockHandle, cedarFilter.handle)
15161517
}
1518+
1519+
// Tests for dynamicMetadataMap / buildContext dynamic metadata
1520+
1521+
func TestBuildContext_DynamicMetadata_MultipleNamespacesAndKeys(t *testing.T) {
1522+
policy := `permit(principal, action, resource);`
1523+
policyFile := createTestPolicyFile(t, policy)
1524+
cfg := &cedarConfig{
1525+
Policy: pkg.DataSource{File: policyFile},
1526+
PrincipalType: "User",
1527+
PrincipalIDHeader: "x-user-id",
1528+
MetadataNamespaces: []string{"ns.auth", "ns.ratelimit"},
1529+
}
1530+
configJSON, err := json.Marshal(cfg)
1531+
require.NoError(t, err)
1532+
1533+
factory := &CedarHttpFilterConfigFactory{}
1534+
ctrl := gomock.NewController(t)
1535+
defer ctrl.Finish()
1536+
1537+
mockConfigHandle := mocks.NewMockHttpFilterConfigHandle(ctrl)
1538+
mockConfigHandle.EXPECT().Log(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
1539+
mockConfigHandle.EXPECT().DefineCounter("cedar_requests_total", "decision").Return(shared.MetricID(1), shared.MetricsSuccess)
1540+
1541+
filterFactory, err := factory.Create(mockConfigHandle, configJSON)
1542+
require.NoError(t, err)
1543+
1544+
mockHandle := mocks.NewMockHttpFilterHandle(ctrl)
1545+
mockHandle.EXPECT().Log(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
1546+
mockHandle.EXPECT().GetAttributeString(gomock.Any()).Return(pkg.UnsafeBufferFromString(""), false).AnyTimes()
1547+
mockHandle.EXPECT().GetAttributeBool(gomock.Any()).Return(false, false).AnyTimes()
1548+
1549+
// Namespace "ns.auth" has two string keys.
1550+
mockHandle.EXPECT().GetMetadataKeys(shared.MetadataSourceTypeDynamic, "ns.auth").Return([]shared.UnsafeEnvoyBuffer{
1551+
pkg.UnsafeBufferFromString("identity"),
1552+
pkg.UnsafeBufferFromString("role"),
1553+
})
1554+
mockHandle.EXPECT().GetMetadataString(shared.MetadataSourceTypeDynamic, "ns.auth", "identity").Return(pkg.UnsafeBufferFromString("user-123"), true)
1555+
mockHandle.EXPECT().GetMetadataString(shared.MetadataSourceTypeDynamic, "ns.auth", "role").Return(pkg.UnsafeBufferFromString("admin"), true)
1556+
1557+
// Namespace "ns.ratelimit" has a string key.
1558+
mockHandle.EXPECT().GetMetadataKeys(shared.MetadataSourceTypeDynamic, "ns.ratelimit").Return([]shared.UnsafeEnvoyBuffer{
1559+
pkg.UnsafeBufferFromString("bucket"),
1560+
})
1561+
mockHandle.EXPECT().GetMetadataString(shared.MetadataSourceTypeDynamic, "ns.ratelimit", "bucket").Return(pkg.UnsafeBufferFromString("default"), true)
1562+
1563+
filter := filterFactory.Create(mockHandle).(*cedarHttpFilter)
1564+
1565+
headers := fake.NewFakeHeaderMap(map[string][]string{
1566+
":method": {"GET"},
1567+
":path": {"/api/resource"},
1568+
":authority": {"example.com"},
1569+
})
1570+
1571+
ctx := filter.buildContext(headers)
1572+
1573+
// Extract the dynamic_metadata record from context.
1574+
dmVal, ok := ctx.Get("dynamic_metadata")
1575+
require.True(t, ok, "dynamic_metadata should be present in context")
1576+
1577+
// Verify ns.auth namespace.
1578+
dmRecord, ok := dmVal.(cedarlib.Record)
1579+
require.True(t, ok)
1580+
authNs, ok := dmRecord.Get("ns.auth")
1581+
require.True(t, ok, "ns.auth namespace should be present")
1582+
authRecord, ok := authNs.(cedarlib.Record)
1583+
require.True(t, ok)
1584+
identity, ok := authRecord.Get("identity")
1585+
require.True(t, ok)
1586+
require.Equal(t, cedarlib.String("user-123"), identity)
1587+
role, ok := authRecord.Get("role")
1588+
require.True(t, ok)
1589+
require.Equal(t, cedarlib.String("admin"), role)
1590+
1591+
// Verify ns.ratelimit namespace.
1592+
rlNs, ok := dmRecord.Get("ns.ratelimit")
1593+
require.True(t, ok, "ns.ratelimit namespace should be present")
1594+
rlRecord, ok := rlNs.(cedarlib.Record)
1595+
require.True(t, ok)
1596+
bucket, ok := rlRecord.Get("bucket")
1597+
require.True(t, ok)
1598+
require.Equal(t, cedarlib.String("default"), bucket)
1599+
}
1600+
1601+
func TestBuildContext_DynamicMetadata_Empty(t *testing.T) {
1602+
policy := `permit(principal, action, resource);`
1603+
filter, _ := createTestFilter(t, &cedarConfig{Policy: pkg.DataSource{Inline: policy}})
1604+
1605+
headers := fake.NewFakeHeaderMap(map[string][]string{
1606+
":method": {"GET"},
1607+
":path": {"/"},
1608+
":authority": {"example.com"},
1609+
})
1610+
1611+
ctx := filter.buildContext(headers)
1612+
1613+
// When no metadata namespaces are configured, dynamic_metadata should be an empty record.
1614+
dmVal, ok := ctx.Get("dynamic_metadata")
1615+
require.True(t, ok, "dynamic_metadata should be present in context")
1616+
_, ok = dmVal.(cedarlib.Record)
1617+
require.True(t, ok, "dynamic_metadata should be a Record")
1618+
}

extensions/composer/integration/envoy.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Copyright Built On Envoy
2+
# SPDX-License-Identifier: Apache-2.0
3+
# The full text of the Apache license is available in the LICENSE file at
4+
# the root of the repo.
5+
16
admin:
27
address:
38
socket_address:

extensions/composer/integration/ftw.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Copyright Built On Envoy
2+
# SPDX-License-Identifier: Apache-2.0
3+
# The full text of the Apache license is available in the LICENSE file at
4+
# the root of the repo.
5+
16
---
27
logfile: "envoy.log"
38
maxmarkerretries: 500

extensions/composer/integration/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import (
2626
)
2727

2828
const (
29-
envoyLogFile = "envoy.log"
29+
envoyLogFile = "envoy.log"
3030
albedoLogFile = "albedo.log"
3131

3232
envoyAdminPort = 9901

website/public/extensions.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@
168168
"author": "Tetrate",
169169
"featured": true,
170170
"description": "Inline Cedar policy evaluation for HTTP request authorization",
171-
"longDescription": "This extension implements [Cedar](https://www.cedarpolicy.com/) policy evaluation\nas an inline Envoy HTTP filter. It uses the [Cedar-Go](https://github.com/cedar-policy/cedar-go)\nlibrary to make authorization decisions on each request without a separate authorization server.\n\n## Key Features\n\n- **Inline Cedar evaluation**: Load and evaluate Cedar policy files directly in the Envoy\n data path using the Cedar-Go library. No separate authorization server required.\n- **Structured authorization model**: Maps HTTP requests to Cedar's Principal/Action/Resource\n model with rich context for fine-grained `when` clause conditions.\n- **Entity hierarchies**: Optionally load Cedar entity data to enable group membership and\n hierarchical authorization checks (e.g., `principal in Group::\"admins\"`).\n- **Dry-run mode**: Log authorization decisions without enforcing them, useful for testing\n policies in production.\n- **Fail-open mode**: Allow requests through when policy evaluation encounters an error,\n instead of returning a 500 response.\n- **mTLS and SPIFFE identity**: Access client certificate attributes including SPIFFE IDs\n (URI SANs), DNS SANs, and certificate subject in policy context.\n- **Configurable deny responses**: Customize the HTTP status code, body, and headers\n returned when a request is denied.\n\n## Authorization Model\n\nHTTP requests are mapped to Cedar authorization concepts:\n\n- **Principal**: Derived from a configurable request header (e.g., `User::\"alice\"` from\n the `x-user-id` header). The entity type is set via `principal_type` and the ID is\n extracted from the header specified by `principal_id_header`.\n- **Action**: Derived from the HTTP method (e.g., `Action::\"GET\"`, `Action::\"POST\"`).\n The entity type defaults to `Action` and can be customized via `action_type`.\n- **Resource**: Derived from the request path without query string (e.g.,\n `Resource::\"/api/users\"`). The entity type defaults to `Resource` and can be\n customized via `resource_type`.\n- **Context**: Rich request attributes available in Cedar `when` clauses.\n\n## Context Record Structure\n\nThe `context` record passed to Cedar policies contains the following structure:\n\n```json\n{\n \"request\": {\n \"method\": \"GET\",\n \"path\": \"/api/v1/resource?key=value\",\n \"host\": \"example.com\",\n \"scheme\": \"https\",\n \"protocol\": \"HTTP/1.1\",\n \"headers\": {\n \"authorization\": \"Bearer ...\",\n \"content-type\": \"application/json\"\n }\n },\n \"source\": {\n \"address\": \"10.0.0.1:5000\",\n \"certificate\": {\n \"uri_san\": \"spiffe://cluster.local/ns/default/sa/client\",\n \"dns_san\": \"client.default.svc.cluster.local\",\n \"subject\": \"CN=client,O=example\",\n \"sha256_digest\": \"abc123...\"\n }\n },\n \"destination\": {\n \"address\": \"10.0.0.2:443\"\n },\n \"connection\": {\n \"mtls\": true,\n \"tls_version\": \"TLSv1.3\"\n },\n \"parsed_path\": [\"api\", \"v1\", \"resource\"],\n \"parsed_query\": {\"key\": [\"value\"]}\n}\n```\n\n## Metrics\n\nThe extension exposes the `cedar_requests_total` counter metric with a `decision` tag that\ntracks authorization outcomes. The possible tag values are:\n\n| Decision | Description |\n|--------|------|\n| `allowed` | The policy allowed the request. |\n| `denied` | The policy denied the request (or an error occurred with fail-open disabled). |\n| `failopen` | An error occurred during policy evaluation and the request was allowed because fail-open mode is enabled. |\n| `dryrun_allow` | The policy denied the request but it was allowed because dry-run mode is enabled. |\n",
171+
"longDescription": "This extension implements [Cedar](https://www.cedarpolicy.com/) policy evaluation\nas an inline Envoy HTTP filter. It uses the [Cedar-Go](https://github.com/cedar-policy/cedar-go)\nlibrary to make authorization decisions on each request without a separate authorization server.\n\n## Key Features\n\n- **Inline Cedar evaluation**: Load and evaluate Cedar policy files directly in the Envoy\n data path using the Cedar-Go library. No separate authorization server required.\n- **Structured authorization model**: Maps HTTP requests to Cedar's Principal/Action/Resource\n model with rich context for fine-grained `when` clause conditions.\n- **Entity hierarchies**: Optionally load Cedar entity data to enable group membership and\n hierarchical authorization checks (e.g., `principal in Group::\"admins\"`).\n- **Dry-run mode**: Log authorization decisions without enforcing them, useful for testing\n policies in production.\n- **Fail-open mode**: Allow requests through when policy evaluation encounters an error,\n instead of returning a 500 response.\n- **mTLS and SPIFFE identity**: Access client certificate attributes including SPIFFE IDs\n (URI SANs), DNS SANs, and certificate subject in policy context.\n- **Configurable deny responses**: Customize the HTTP status code, body, and headers\n returned when a request is denied.\n\n## Authorization Model\n\nHTTP requests are mapped to Cedar authorization concepts:\n\n- **Principal**: Derived from a configurable request header (e.g., `User::\"alice\"` from\n the `x-user-id` header). The entity type is set via `principal_type` and the ID is\n extracted from the header specified by `principal_id_header`.\n- **Action**: Derived from the HTTP method (e.g., `Action::\"GET\"`, `Action::\"POST\"`).\n The entity type defaults to `Action` and can be customized via `action_type`.\n- **Resource**: Derived from the request path without query string (e.g.,\n `Resource::\"/api/users\"`). The entity type defaults to `Resource` and can be\n customized via `resource_type`.\n- **Context**: Rich request attributes available in Cedar `when` clauses.\n\n## Context Record Structure\n\nThe `context` record passed to Cedar policies contains the following structure:\n\n```json\n{\n \"request\": {\n \"method\": \"GET\",\n \"path\": \"/api/v1/resource?key=value\",\n \"host\": \"example.com\",\n \"scheme\": \"https\",\n \"protocol\": \"HTTP/1.1\",\n \"headers\": {\n \"authorization\": \"Bearer ...\",\n \"content-type\": \"application/json\"\n }\n },\n \"source\": {\n \"address\": \"10.0.0.1:5000\",\n \"certificate\": {\n \"uri_san\": \"spiffe://cluster.local/ns/default/sa/client\",\n \"dns_san\": \"client.default.svc.cluster.local\",\n \"subject\": \"CN=client,O=example\",\n \"sha256_digest\": \"abc123...\"\n }\n },\n \"destination\": {\n \"address\": \"10.0.0.2:443\"\n },\n \"connection\": {\n \"mtls\": true,\n \"tls_version\": \"TLSv1.3\"\n },\n \"dynamic_metadata\": {\n \"envoy.filters.http.jwt_authn\": {\n \"sub\": \"user-123\",\n \"iss\": \"https://issuer.example.com\"\n }\n },\n \"parsed_path\": [\"api\", \"v1\", \"resource\"],\n \"parsed_query\": {\"key\": [\"value\"]}\n}\n```\n\n## Metrics\n\nThe extension exposes the `cedar_requests_total` counter metric with a `decision` tag that\ntracks authorization outcomes. The possible tag values are:\n\n| Decision | Description |\n|--------|------|\n| `allowed` | The policy allowed the request. |\n| `denied` | The policy denied the request (or an error occurred with fail-open disabled). |\n| `failopen` | An error occurred during policy evaluation and the request was allowed because fail-open mode is enabled. |\n| `dryrun_allow` | The policy denied the request but it was allowed because dry-run mode is enabled. |\n",
172172
"type": "go",
173173
"tags": [
174174
"go",
@@ -222,6 +222,11 @@
222222
"title": "Fail-Open Mode",
223223
"description": "Enable fail-open mode to allow requests through when policy evaluation\nencounters an error, instead of returning a 500 response. Useful for\nnon-critical authorization checks where availability is prioritized\nover enforcement.\n",
224224
"code": "boe run --extension cedar-auth --config '{\n \"policy\": {\"file\": \"/tmp/policy.cedar\"},\n \"principal_type\": \"User\",\n \"principal_id_header\": \"x-user-id\",\n \"fail_open\": true\n}'\n"
225+
},
226+
{
227+
"title": "Include Envoy dynamic metadata in Cedar context",
228+
"description": "Include Envoy dynamic metadata in the Cedar context record under `context.dynamic_metadata`.\nThis allows writing policies that make decisions based on dynamic metadata from other Envoy\nfilters, such as JWT authentication claims.\n",
229+
"code": "boe run --extension cedar-auth --config '{\n \"policy\": {\"file\": \"/tmp/policy.cedar\"},\n \"principal_type\": \"User\",\n \"principal_id_header\": \"x-user-id\",\n \"metadata_namespaces\": [\"envoy.filters.http.jwt_authn\"]\n}'\n"
225230
}
226231
],
227232
"minEnvoyVersion": "1.37.1",

0 commit comments

Comments
 (0)