Skip to content

Commit 32d7d99

Browse files
authored
Merge pull request #52 from constructive-io/devin/1767651595-cel-proto-parser
feat(cel-proto-parser): add CEL proto parser and deparser package
2 parents 882d5e9 + 8bc02b6 commit 32d7d99

27 files changed

Lines changed: 4065 additions & 0 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# cel-proto-parser
2+
3+
CEL (Common Expression Language) proto parser and deparser for TypeScript.
4+
5+
## Overview
6+
7+
This package provides tools for working with CEL (Common Expression Language) Abstract Syntax Trees (ASTs):
8+
9+
1. **Proto Parser**: Parse CEL protobuf definitions to generate TypeScript types
10+
2. **Deparser**: Convert CEL AST back to CEL expression strings
11+
3. **AST Helpers**: Factory functions for constructing CEL AST nodes
12+
13+
## Installation
14+
15+
```bash
16+
npm install cel-proto-parser
17+
```
18+
19+
## Usage
20+
21+
### Deparser
22+
23+
Convert CEL AST to expression strings:
24+
25+
```typescript
26+
import { deparse, Expr } from 'cel-proto-parser';
27+
28+
// Simple expression: x > 5
29+
const expr: Expr = {
30+
callExpr: {
31+
function: '_>_',
32+
args: [
33+
{ identExpr: { name: 'x' } },
34+
{ constExpr: { int64Value: 5 } }
35+
]
36+
}
37+
};
38+
39+
console.log(deparse(expr)); // Output: "x > 5"
40+
```
41+
42+
### Proto Parser
43+
44+
Generate TypeScript types from CEL proto files:
45+
46+
```typescript
47+
import { CelProtoParser } from 'cel-proto-parser';
48+
49+
const parser = new CelProtoParser('path/to/syntax.proto', {
50+
outDir: './generated',
51+
types: { enabled: true },
52+
enums: { enabled: true },
53+
utils: { astHelpers: { enabled: true } },
54+
deparser: { enabled: true }
55+
});
56+
57+
parser.write();
58+
```
59+
60+
## CEL Expression Types
61+
62+
The deparser supports all CEL expression types:
63+
64+
- **Constants**: `null`, `true`, `false`, integers, floats, strings, bytes
65+
- **Identifiers**: `request`, `user`, etc.
66+
- **Field Access**: `request.auth.claims`
67+
- **Function Calls**: `size(list)`, `str.startsWith("prefix")`
68+
- **Operators**: `+`, `-`, `*`, `/`, `%`, `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, `-` (unary)
69+
- **Ternary**: `condition ? trueExpr : falseExpr`
70+
- **Index Access**: `list[0]`, `map["key"]`
71+
- **Lists**: `[1, 2, 3]`
72+
- **Maps**: `{"key": value}`
73+
- **Message Construction**: `MyMessage{field: value}`
74+
- **Macros**: `has()`, `all()`, `exists()`, etc.
75+
76+
## API Reference
77+
78+
### `deparse(expr: Expr, options?: DeparserOptions): string`
79+
80+
Converts a CEL AST expression to a string.
81+
82+
Options:
83+
- `spaces`: Whether to add spaces around operators (default: `true`)
84+
85+
### `CelProtoParser`
86+
87+
Parses CEL proto files and generates TypeScript code.
88+
89+
Constructor options:
90+
- `outDir`: Output directory for generated files
91+
- `types.enabled`: Generate TypeScript interfaces
92+
- `enums.enabled`: Generate TypeScript enums
93+
- `utils.astHelpers.enabled`: Generate AST helper functions
94+
- `deparser.enabled`: Generate deparser module
95+
96+
## License
97+
98+
MIT
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Basic CEL Expression Fixtures for Round-Trip Testing
2+
# A small handful of representative expressions
3+
4+
# Simple values
5+
42
6+
3.14
7+
true
8+
"hello"
9+
10+
# Field access
11+
request.auth
12+
request.auth.claims.email
13+
14+
# Arithmetic
15+
1 + 2
16+
a * b + c
17+
18+
# Comparison
19+
x == y
20+
size(list) > 0
21+
22+
# Logical
23+
a && b
24+
!enabled
25+
26+
# Ternary
27+
x ? 1 : 2
28+
29+
# Collections
30+
[1, 2, 3]
31+
{"key": "value"}
32+
33+
# Function calls
34+
size(items)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# CEL Expression Fixtures
2+
# Each line is a CEL expression to test for round-trip parsing
3+
# Lines starting with # are comments
4+
5+
# Literals
6+
1
7+
42
8+
-5
9+
3.14
10+
-2.5
11+
1.0
12+
true
13+
false
14+
null
15+
"hello"
16+
"hello world"
17+
"with \"quotes\""
18+
"with\nnewline"
19+
""
20+
21+
# Unsigned integers
22+
1u
23+
42u
24+
0u
25+
26+
# Identifiers
27+
x
28+
foo
29+
request
30+
_underscore
31+
camelCase
32+
33+
# Field access
34+
request.auth
35+
request.auth.claims
36+
request.auth.claims.email
37+
a.b.c.d
38+
39+
# Arithmetic operators
40+
1 + 2
41+
a + b
42+
x - y
43+
a * b
44+
x / y
45+
a % b
46+
1 + 2 * 3
47+
(1 + 2) * 3
48+
49+
# Comparison operators
50+
a == b
51+
x != y
52+
a < b
53+
a <= b
54+
a > b
55+
a >= b
56+
57+
# Logical operators
58+
a && b
59+
x || y
60+
!a
61+
a && b && c
62+
a || b || c
63+
a && b || c
64+
(a || b) && c
65+
66+
# Ternary conditional
67+
x ? 1 : 2
68+
a == b ? "yes" : "no"
69+
x > 0 ? x : -x
70+
71+
# List literals
72+
[]
73+
[1]
74+
[1, 2, 3]
75+
[a, b, c]
76+
["a", "b", "c"]
77+
78+
# Map literals
79+
{}
80+
{"a": 1}
81+
{"a": 1, "b": 2}
82+
{1: "one", 2: "two"}
83+
84+
# Index access
85+
list[0]
86+
map["key"]
87+
a[b]
88+
list[i + 1]
89+
90+
# Function calls
91+
size(list)
92+
int(x)
93+
string(42)
94+
type(x)
95+
96+
# Method calls
97+
list.size()
98+
str.contains("test")
99+
list.map(x, x * 2)
100+
101+
# In operator
102+
x in list
103+
"admin" in roles
104+
key in map
105+
106+
# Complex expressions
107+
request.auth.claims.email == "admin@example.com"
108+
size(request.body) > 0 && request.method == "POST"
109+
user.age >= 18 && "admin" in user.roles
110+
items.all(x, x > 0)
111+
items.exists(x, x == target)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Policy-Shaped CEL Expression Fixtures
2+
# OPA-ish patterns useful for Kubernetes admission control
3+
# Rich in syntax variety for deparser testing
4+
5+
# 1) Core allow/deny shapes
6+
request.user == "system:admin"
7+
request.user in ["alice", "bob", "carol"]
8+
request.verb in ["create", "update"] && request.resource == "pods"
9+
!(request.user.startsWith("system:"))
10+
request.namespace != "kube-system" && request.namespace != "kube-public"
11+
12+
# 2) Field presence / null-ish handling
13+
has(object.metadata.labels) && has(object.metadata.labels["app"])
14+
!has(object.spec.replicas) || object.spec.replicas <= 10
15+
has(object.spec.template) ? object.spec.template.spec.nodeName == "" : true
16+
has(object.spec.securityContext) && has(object.spec.securityContext.runAsNonRoot) && object.spec.securityContext.runAsNonRoot == true
17+
has(params.exemptUsers) && request.user in params.exemptUsers
18+
19+
# 3) Map + list literals, indexing, membership
20+
object.metadata.labels["team"] in ["infra", "platform"]
21+
object.metadata.annotations["owner"] == request.user
22+
size(object.spec.containers) > 0
23+
object.spec.containers[0].image.matches("^ghcr\\.io/constructive-io/.+")
24+
25+
# 4) String ops + regex escaping edge cases
26+
request.user.matches("^user:[a-z0-9_-]{3,32}$")
27+
object.metadata.name.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
28+
object.spec.containers.exists(c, c.name.startsWith("sidecar-") && c.image.contains("@sha256:"))
29+
object.spec.serviceAccountName != "" && !object.spec.serviceAccountName.startsWith("default")
30+
31+
# 5) Quantifiers/macros (great for AST/deparser)
32+
object.spec.containers.all(c, !c.securityContext.privileged)
33+
object.spec.containers.exists(c, has(c.resources.limits["cpu"]) && has(c.resources.limits["memory"]))
34+
object.spec.containers.filter(c, c.image.contains(":latest")).size() == 0
35+
object.spec.containers.map(c, c.name).exists(n, n == "app")
36+
37+
# 6) Nested logic + precedence stress tests
38+
request.verb == "update" && (request.user == "alice" || request.user == "bob") && !request.dryRun
39+
(request.user in params.admins) || (request.namespace == "dev" && request.user.matches("^user:dev_.*$"))
40+
!(request.verb == "delete" && request.resource == "namespaces")
41+
42+
# 7) Numeric + bounds + ternary
43+
has(object.spec.replicas) ? (object.spec.replicas >= 1 && object.spec.replicas <= 20) : true
44+
object.spec.containers.all(c, c.resources.requests["cpu"] <= c.resources.limits["cpu"])
45+
46+
# 8) "OPA-ish" patterns (rule-ish in one expression)
47+
request.user in params.admins || (request.verb in ["get","list","watch"] && request.namespace in params.readNamespaces)
48+
request.user == object.metadata.annotations["owner"] || request.user in params.delegates[object.metadata.annotations["owner"]]
49+
50+
# 9) Equality against structured literals (map/list)
51+
object.metadata.labels == {"app": "api", "tier": "backend"}
52+
object.spec.tolerations.exists(t, t == {"key":"dedicated","operator":"Equal","value":"gpu","effect":"NoSchedule"})
53+
54+
# 10) "Weird but valid" deparser edge cases
55+
"" == ""
56+
true ? false ? true : false : true
57+
((a + b) * c) / (d - 1) > 0
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# RLS Policy Template CEL Expressions
2+
# Maps to common policy templates: direct_owner, membership, permission bits, etc.
3+
# Uses: row (table row), auth (auth context), acl (ACL table view), params (compile-time params)
4+
5+
# 1) direct_owner (row.owner_id == user_id)
6+
row.owner_id == auth.user_id
7+
has(row.owner_id) && row.owner_id == auth.user_id
8+
9+
# 2) multi_owner / any_owner (row.sender_id OR row.receiver_id)
10+
auth.user_id in [row.sender_id, row.receiver_id]
11+
12+
# 3) membership_by_field (row.org_id exists in ACL for user)
13+
acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "org" && m.entity_id == row.org_id)
14+
15+
# 4) membership_by_join (row -> join table -> memberships)
16+
acl.joins.exists(j, j.row_id == row.id && acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "group" && m.entity_id == j.entity_id))
17+
18+
# 5) scoped_owner / scope_owner (membership exists AND matches scope)
19+
acl.memberships.exists(m, m.user_id == auth.user_id && m.scope == "project" && m.entity_id == row.project_id)
20+
acl.memberships.exists(m, m.user_id == auth.user_id && m.membership_type == params.membership_type && m.entity_id == row.entity_id)
21+
22+
# 6) hasAccess / permission-bit checks (bitmask)
23+
# NOTE: @marcbachmann/cel-js does not support bitwise AND (&) operator
24+
# These would be: acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_id == row.org_id && (m.perms & params.required_perm) != 0)
25+
26+
# 7) Role-based overrides (auth.roles)
27+
("admin" in auth.roles) || (row.owner_id == auth.user_id)
28+
auth.user.startsWith("system:") || row.owner_id == auth.user_id
29+
30+
# 8) acl_exists (no entity match; "member of ANY org")
31+
acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "org")
32+
33+
# 9) relatedAccess / relationship ownership (row references another object)
34+
acl.parents.exists(p, p.id == row.parent_id && (p.owner_id == auth.user_id || acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_id == p.org_id)))
35+
36+
# 10) Array membership / group array checks
37+
has(row.allowed_user_ids) && auth.user_id in row.allowed_user_ids
38+
row.group_ids.exists(gid, acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "group" && m.entity_id == gid))
39+
40+
# 11) "Owner OR membership" compound policy (very common)
41+
row.owner_id == auth.user_id || acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_id == row.org_id)
42+
43+
# 12) Insert/update "WITH CHECK" style (new row constraints)
44+
row.owner_id == auth.user_id
45+
newRow.owner_id == auth.user_id
46+
request.verb == "update" ? (newRow.owner_id == oldRow.owner_id) : true

0 commit comments

Comments
 (0)