@highflame/policy 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotations.d.ts +127 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/annotations.js +175 -0
- package/dist/annotations.js.map +1 -0
- package/dist/builder.d.ts +114 -25
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +295 -113
- package/dist/builder.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +18 -11
- package/dist/parser.js.map +1 -1
- package/dist/parser.test.js +2 -2
- package/dist/parser.test.js.map +1 -1
- package/dist/studio-ui.test.js +436 -0
- package/dist/studio-ui.test.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/annotations.ts +243 -0
- package/src/builder.ts +386 -127
- package/src/index.ts +1 -0
- package/src/parser.test.ts +2 -2
- package/src/parser.ts +20 -12
- package/src/studio-ui.test.ts +499 -0
- package/src/types.ts +3 -0
package/src/builder.ts
CHANGED
|
@@ -14,29 +14,103 @@
|
|
|
14
14
|
* .when("context.environment == \"production\"")
|
|
15
15
|
* .build();
|
|
16
16
|
*
|
|
17
|
-
* // Get Cedar policy text
|
|
17
|
+
* // Get Cedar policy text (with proper @annotations)
|
|
18
18
|
* const cedarText = policy.toCedar();
|
|
19
19
|
*
|
|
20
20
|
* // Get JSON representation (for storage/editing)
|
|
21
21
|
* const policyJson = policy.toJSON();
|
|
22
22
|
* ```
|
|
23
|
+
*
|
|
24
|
+
* Cedar Annotations:
|
|
25
|
+
* Policies include proper Cedar annotations that are embedded in the policy text:
|
|
26
|
+
* ```cedar
|
|
27
|
+
* @id("rule-001")
|
|
28
|
+
* @name("Block critical threats")
|
|
29
|
+
* @severity("high")
|
|
30
|
+
* permit(...) when {...};
|
|
31
|
+
* ```
|
|
23
32
|
*/
|
|
24
33
|
|
|
25
34
|
import { EntityType, EntityUID } from './entities.gen.js';
|
|
26
35
|
import { ActionType } from './actions.gen.js';
|
|
36
|
+
import {
|
|
37
|
+
type PolicyAnnotations,
|
|
38
|
+
type CustomAnnotations,
|
|
39
|
+
type PolicySeverity,
|
|
40
|
+
generateAnnotationLines,
|
|
41
|
+
generateRuleId,
|
|
42
|
+
} from './annotations.js';
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Security: Input Validation and Escaping
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Valid identifier pattern for Cedar (alphanumeric, underscore, with optional namespace).
|
|
50
|
+
*/
|
|
51
|
+
const VALID_IDENTIFIER_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pattern that matches potentially dangerous content in raw conditions.
|
|
55
|
+
*/
|
|
56
|
+
const DANGEROUS_PATTERN_REGEX = /[;}]|\/\/|\/\*|\*\/|permit\s*\(|forbid\s*\(/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Escape a string value for use in Cedar string literals.
|
|
60
|
+
* This prevents injection attacks by escaping backslashes and double quotes.
|
|
61
|
+
*/
|
|
62
|
+
function escapeCedarString(value: string): string {
|
|
63
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a string is a valid Cedar identifier.
|
|
68
|
+
*/
|
|
69
|
+
function isValidIdentifier(s: string): boolean {
|
|
70
|
+
return VALID_IDENTIFIER_REGEX.test(s);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sanitize an identifier, replacing invalid characters with underscores.
|
|
75
|
+
*/
|
|
76
|
+
function sanitizeIdentifier(s: string, context: string): string {
|
|
77
|
+
if (isValidIdentifier(s)) {
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
80
|
+
// Replace invalid characters with underscores
|
|
81
|
+
const sanitized = s.replace(/[^A-Za-z0-9_:]/g, '_');
|
|
82
|
+
if (sanitized === '' || !isValidIdentifier(sanitized)) {
|
|
83
|
+
return `invalid_${context}`;
|
|
84
|
+
}
|
|
85
|
+
return sanitized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate a raw condition string for potentially dangerous patterns.
|
|
90
|
+
* Returns true if the condition is safe to use.
|
|
91
|
+
*/
|
|
92
|
+
function isValidRawCondition(condition: string): boolean {
|
|
93
|
+
return !DANGEROUS_PATTERN_REGEX.test(condition);
|
|
94
|
+
}
|
|
27
95
|
|
|
28
96
|
/**
|
|
29
97
|
* Format an action string for Cedar policy text.
|
|
30
98
|
* Detects if action is already namespaced (contains 'Action::"') and preserves it,
|
|
31
99
|
* otherwise wraps with Action::"...".
|
|
100
|
+
* Escapes the action name to prevent injection attacks.
|
|
32
101
|
*/
|
|
33
102
|
function formatAction(action: string): string {
|
|
34
103
|
if (action.includes('Action::"')) {
|
|
35
|
-
// Already namespaced
|
|
104
|
+
// Already namespaced - extract and escape the action name
|
|
105
|
+
const parts = action.split('Action::"');
|
|
106
|
+
if (parts.length === 2) {
|
|
107
|
+
const actionName = parts[1].replace(/"$/, '');
|
|
108
|
+
return `${parts[0]}Action::"${escapeCedarString(actionName)}"`;
|
|
109
|
+
}
|
|
36
110
|
return action;
|
|
37
111
|
}
|
|
38
112
|
// Non-namespaced, wrap with Action::"..."
|
|
39
|
-
return `Action::"${action}"`;
|
|
113
|
+
return `Action::"${escapeCedarString(action)}"`;
|
|
40
114
|
}
|
|
41
115
|
|
|
42
116
|
/**
|
|
@@ -88,14 +162,14 @@ export type PolicyPrincipal = PolicyEntity;
|
|
|
88
162
|
/** Alias for PolicyEntity when used as resource constraint */
|
|
89
163
|
export type PolicyResource = PolicyEntity;
|
|
90
164
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*/
|
|
94
|
-
export type PolicySeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
165
|
+
// Re-export PolicySeverity from annotations for backwards compatibility
|
|
166
|
+
export type { PolicySeverity } from './annotations.js';
|
|
95
167
|
|
|
96
168
|
/**
|
|
97
169
|
* JSON representation of a policy for storage and editing.
|
|
98
|
-
* This is the base interface used by PolicyBuilder.
|
|
170
|
+
* This is the base interface used by PolicyBuilder (legacy format).
|
|
171
|
+
*
|
|
172
|
+
* @deprecated Use PolicyRule with annotations for new code.
|
|
99
173
|
*/
|
|
100
174
|
export interface PolicyJSON {
|
|
101
175
|
/** Unique identifier for this policy */
|
|
@@ -117,155 +191,125 @@ export interface PolicyJSON {
|
|
|
117
191
|
}
|
|
118
192
|
|
|
119
193
|
/**
|
|
120
|
-
* A policy rule with
|
|
121
|
-
* Extends PolicyJSON with fields needed for UI editing and database storage.
|
|
194
|
+
* A policy rule with full Cedar annotation support.
|
|
122
195
|
*
|
|
123
196
|
* This is the canonical type used across all Highflame services:
|
|
124
197
|
* - highflame-studio (UI)
|
|
125
198
|
* - highflame-authz (Go backend)
|
|
126
199
|
* - Any Python services
|
|
127
200
|
*
|
|
128
|
-
* Each PolicyRule maps 1:1 to a Cedar policy statement.
|
|
201
|
+
* Each PolicyRule maps 1:1 to a Cedar policy statement with proper annotations.
|
|
202
|
+
*
|
|
203
|
+
* Annotations are embedded in Cedar text:
|
|
204
|
+
* ```cedar
|
|
205
|
+
* @id("rule-001")
|
|
206
|
+
* @name("Block critical threats")
|
|
207
|
+
* @severity("high")
|
|
208
|
+
* @tags("security,baseline")
|
|
209
|
+
* @compliance("SOC2")
|
|
210
|
+
* forbid(...) when {...};
|
|
211
|
+
* ```
|
|
129
212
|
*/
|
|
130
|
-
export interface PolicyRule
|
|
131
|
-
/**
|
|
213
|
+
export interface PolicyRule {
|
|
214
|
+
/** Predefined annotations (embedded in Cedar text) */
|
|
215
|
+
annotations: PolicyAnnotations;
|
|
216
|
+
/** Custom user-defined annotations (embedded in Cedar text) */
|
|
217
|
+
customAnnotations?: CustomAnnotations;
|
|
218
|
+
|
|
219
|
+
/** Policy effect - permit or forbid */
|
|
220
|
+
effect: PolicyEffect;
|
|
221
|
+
/** Principal constraint */
|
|
222
|
+
principal: PolicyEntity | null;
|
|
223
|
+
/** Action constraint - single action or array of actions */
|
|
224
|
+
action: string | string[];
|
|
225
|
+
/** Resource constraint */
|
|
226
|
+
resource: PolicyEntity | null;
|
|
227
|
+
/** Structured conditions (when clause) */
|
|
228
|
+
conditions: PolicyCondition[];
|
|
229
|
+
/** Raw condition string (for advanced/complex conditions) */
|
|
230
|
+
rawCondition?: string;
|
|
231
|
+
|
|
232
|
+
/** Whether this rule is active - NOT embedded in Cedar (runtime state) */
|
|
233
|
+
enabled: boolean;
|
|
234
|
+
/** Display/evaluation order - NOT embedded in Cedar (runtime state) */
|
|
235
|
+
order: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Legacy PolicyRule format for backwards compatibility.
|
|
240
|
+
* Used when parsing policies that don't have the new annotations structure.
|
|
241
|
+
*
|
|
242
|
+
* @deprecated Use PolicyRule with annotations for new code.
|
|
243
|
+
*/
|
|
244
|
+
export interface LegacyPolicyRule extends PolicyJSON {
|
|
132
245
|
enabled: boolean;
|
|
133
|
-
/** Display/evaluation order (0-indexed) */
|
|
134
246
|
order: number;
|
|
135
|
-
/** Optional description (separate from name for longer explanations) */
|
|
136
247
|
description?: string;
|
|
137
|
-
/** Rule severity for display and prioritization */
|
|
138
248
|
severity?: PolicySeverity;
|
|
139
|
-
/** Optional tags for categorization and filtering */
|
|
140
249
|
tags?: string[];
|
|
141
250
|
}
|
|
142
251
|
|
|
143
252
|
/**
|
|
144
|
-
*
|
|
253
|
+
* Convert a legacy PolicyRule to the new annotations-based format.
|
|
254
|
+
*/
|
|
255
|
+
export function convertLegacyRule(legacy: LegacyPolicyRule, index: number = 0): PolicyRule {
|
|
256
|
+
return {
|
|
257
|
+
annotations: {
|
|
258
|
+
id: legacy.id || generateRuleId(),
|
|
259
|
+
name: legacy.name || legacy.id || `Rule ${index + 1}`,
|
|
260
|
+
description: legacy.description,
|
|
261
|
+
severity: legacy.severity,
|
|
262
|
+
tags: legacy.tags,
|
|
263
|
+
},
|
|
264
|
+
effect: legacy.effect,
|
|
265
|
+
principal: legacy.principal,
|
|
266
|
+
action: legacy.action,
|
|
267
|
+
resource: legacy.resource,
|
|
268
|
+
conditions: legacy.conditions,
|
|
269
|
+
rawCondition: legacy.rawCondition,
|
|
270
|
+
enabled: legacy.enabled,
|
|
271
|
+
order: legacy.order,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A built policy that can be converted to Cedar text or JSON.
|
|
277
|
+
* This class is used by PolicyBuilder for the legacy API.
|
|
278
|
+
*
|
|
279
|
+
* For new code, use ruleToCedar() and rulesToCedar() functions with PolicyRule.
|
|
145
280
|
*/
|
|
146
281
|
export class Policy {
|
|
147
282
|
constructor(private readonly data: PolicyJSON) {}
|
|
148
283
|
|
|
149
284
|
/**
|
|
150
|
-
* Convert to Cedar policy text
|
|
285
|
+
* Convert to Cedar policy text.
|
|
286
|
+
* Uses proper Cedar @annotation syntax.
|
|
151
287
|
*/
|
|
152
288
|
toCedar(): string {
|
|
153
289
|
const lines: string[] = [];
|
|
154
290
|
|
|
155
|
-
//
|
|
156
|
-
if (this.data.name) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
291
|
+
// Generate proper Cedar annotations
|
|
292
|
+
if (this.data.id || this.data.name) {
|
|
293
|
+
const annotations: PolicyAnnotations = {
|
|
294
|
+
id: this.data.id || generateRuleId(),
|
|
295
|
+
name: this.data.name || this.data.id || 'Unnamed Policy',
|
|
296
|
+
};
|
|
297
|
+
lines.push(...generateAnnotationLines(annotations));
|
|
161
298
|
}
|
|
162
299
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
} else {
|
|
174
|
-
policyLine += `\n principal`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Action
|
|
178
|
-
if (Array.isArray(this.data.action)) {
|
|
179
|
-
if (this.data.action.length === 1) {
|
|
180
|
-
policyLine += `,\n action == ${formatAction(this.data.action[0])}`;
|
|
181
|
-
} else {
|
|
182
|
-
const actions = this.data.action.map(a => formatAction(a)).join(', ');
|
|
183
|
-
policyLine += `,\n action in [${actions}]`;
|
|
184
|
-
}
|
|
185
|
-
} else {
|
|
186
|
-
policyLine += `,\n action == ${formatAction(this.data.action)}`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Resource
|
|
190
|
-
if (this.data.resource) {
|
|
191
|
-
if (this.data.resource.id) {
|
|
192
|
-
policyLine += `,\n resource == ${this.data.resource.type}::\"${this.data.resource.id}\"`;
|
|
193
|
-
} else {
|
|
194
|
-
policyLine += `,\n resource is ${this.data.resource.type}`;
|
|
195
|
-
}
|
|
196
|
-
} else {
|
|
197
|
-
policyLine += `,\n resource`;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
policyLine += '\n)';
|
|
201
|
-
lines.push(policyLine);
|
|
202
|
-
|
|
203
|
-
// When clause
|
|
204
|
-
if (this.data.rawCondition) {
|
|
205
|
-
lines.push(`when { ${this.data.rawCondition} };`);
|
|
206
|
-
} else if (this.data.conditions.length > 0) {
|
|
207
|
-
const conditionStr = this.data.conditions
|
|
208
|
-
.map(c => this.conditionToCedar(c))
|
|
209
|
-
.join(' && ');
|
|
210
|
-
lines.push(`when { ${conditionStr} };`);
|
|
211
|
-
} else {
|
|
212
|
-
lines.push(';');
|
|
213
|
-
}
|
|
300
|
+
// Generate policy body
|
|
301
|
+
lines.push(generatePolicyBody(
|
|
302
|
+
this.data.effect,
|
|
303
|
+
this.data.principal,
|
|
304
|
+
this.data.action,
|
|
305
|
+
this.data.resource,
|
|
306
|
+
this.data.conditions,
|
|
307
|
+
this.data.rawCondition
|
|
308
|
+
));
|
|
214
309
|
|
|
215
310
|
return lines.join('\n');
|
|
216
311
|
}
|
|
217
312
|
|
|
218
|
-
/**
|
|
219
|
-
* Convert a condition to Cedar syntax
|
|
220
|
-
*/
|
|
221
|
-
private conditionToCedar(condition: PolicyCondition): string {
|
|
222
|
-
const { field, operator, value } = condition;
|
|
223
|
-
const valueStr = this.valueToString(value);
|
|
224
|
-
|
|
225
|
-
switch (operator) {
|
|
226
|
-
case 'eq':
|
|
227
|
-
return `context.${field} == ${valueStr}`;
|
|
228
|
-
case 'neq':
|
|
229
|
-
return `context.${field} != ${valueStr}`;
|
|
230
|
-
case 'lt':
|
|
231
|
-
return `context.${field} < ${valueStr}`;
|
|
232
|
-
case 'lte':
|
|
233
|
-
return `context.${field} <= ${valueStr}`;
|
|
234
|
-
case 'gt':
|
|
235
|
-
return `context.${field} > ${valueStr}`;
|
|
236
|
-
case 'gte':
|
|
237
|
-
return `context.${field} >= ${valueStr}`;
|
|
238
|
-
case 'contains':
|
|
239
|
-
return `context.${field}.contains(${valueStr})`;
|
|
240
|
-
case 'in':
|
|
241
|
-
if (Array.isArray(value)) {
|
|
242
|
-
const items = value.map(v => `\"${v}\"`).join(', ');
|
|
243
|
-
return `context.${field} in [${items}]`;
|
|
244
|
-
}
|
|
245
|
-
return `context.${field} in ${valueStr}`;
|
|
246
|
-
case 'like':
|
|
247
|
-
return `context.${field} like ${valueStr}`;
|
|
248
|
-
default:
|
|
249
|
-
return `context.${field} == ${valueStr}`;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Convert a value to Cedar string representation
|
|
255
|
-
*/
|
|
256
|
-
private valueToString(value: string | number | boolean | string[]): string {
|
|
257
|
-
if (typeof value === 'string') {
|
|
258
|
-
return `\"${value}\"`;
|
|
259
|
-
}
|
|
260
|
-
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
261
|
-
return String(value);
|
|
262
|
-
}
|
|
263
|
-
if (Array.isArray(value)) {
|
|
264
|
-
return `[${value.map(v => `\"${v}\"`).join(', ')}]`;
|
|
265
|
-
}
|
|
266
|
-
return String(value);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
313
|
/**
|
|
270
314
|
* Get JSON representation for storage
|
|
271
315
|
*/
|
|
@@ -288,6 +332,221 @@ export class Policy {
|
|
|
288
332
|
}
|
|
289
333
|
}
|
|
290
334
|
|
|
335
|
+
// ============================================================================
|
|
336
|
+
// Cedar Generation Functions
|
|
337
|
+
// ============================================================================
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Convert a condition to Cedar syntax.
|
|
341
|
+
* Field names are sanitized to prevent injection attacks.
|
|
342
|
+
*/
|
|
343
|
+
function conditionToCedar(condition: PolicyCondition): string {
|
|
344
|
+
const field = sanitizeIdentifier(condition.field, 'field');
|
|
345
|
+
const { operator, value } = condition;
|
|
346
|
+
const valueStr = valueToString(value);
|
|
347
|
+
|
|
348
|
+
switch (operator) {
|
|
349
|
+
case 'eq':
|
|
350
|
+
return `context.${field} == ${valueStr}`;
|
|
351
|
+
case 'neq':
|
|
352
|
+
return `context.${field} != ${valueStr}`;
|
|
353
|
+
case 'lt':
|
|
354
|
+
return `context.${field} < ${valueStr}`;
|
|
355
|
+
case 'lte':
|
|
356
|
+
return `context.${field} <= ${valueStr}`;
|
|
357
|
+
case 'gt':
|
|
358
|
+
return `context.${field} > ${valueStr}`;
|
|
359
|
+
case 'gte':
|
|
360
|
+
return `context.${field} >= ${valueStr}`;
|
|
361
|
+
case 'contains':
|
|
362
|
+
return `context.${field}.contains(${valueStr})`;
|
|
363
|
+
case 'in':
|
|
364
|
+
if (Array.isArray(value)) {
|
|
365
|
+
const items = value.map(v => `"${escapeCedarString(v)}"`).join(', ');
|
|
366
|
+
return `context.${field} in [${items}]`;
|
|
367
|
+
}
|
|
368
|
+
return `context.${field} in ${valueStr}`;
|
|
369
|
+
case 'like':
|
|
370
|
+
return `context.${field} like ${valueStr}`;
|
|
371
|
+
default:
|
|
372
|
+
return `context.${field} == ${valueStr}`;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Convert a value to Cedar string representation.
|
|
378
|
+
* String values are escaped to prevent injection attacks.
|
|
379
|
+
*/
|
|
380
|
+
function valueToString(value: string | number | boolean | string[]): string {
|
|
381
|
+
if (typeof value === 'string') {
|
|
382
|
+
return `"${escapeCedarString(value)}"`;
|
|
383
|
+
}
|
|
384
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
385
|
+
return String(value);
|
|
386
|
+
}
|
|
387
|
+
if (Array.isArray(value)) {
|
|
388
|
+
return `[${value.map(v => `"${escapeCedarString(v)}"`).join(', ')}]`;
|
|
389
|
+
}
|
|
390
|
+
return String(value);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Generate the Cedar policy body (permit/forbid statement).
|
|
395
|
+
* All inputs are sanitized/escaped to prevent injection attacks.
|
|
396
|
+
*/
|
|
397
|
+
function generatePolicyBody(
|
|
398
|
+
effect: PolicyEffect,
|
|
399
|
+
principal: PolicyEntity | null,
|
|
400
|
+
action: string | string[],
|
|
401
|
+
resource: PolicyEntity | null,
|
|
402
|
+
conditions: PolicyCondition[],
|
|
403
|
+
rawCondition?: string
|
|
404
|
+
): string {
|
|
405
|
+
let policyLine = `${effect} (`;
|
|
406
|
+
|
|
407
|
+
// Principal
|
|
408
|
+
if (principal) {
|
|
409
|
+
const entityType = sanitizeIdentifier(principal.type, 'principal_type');
|
|
410
|
+
if (principal.id) {
|
|
411
|
+
policyLine += `\n principal == ${entityType}::"${escapeCedarString(principal.id)}"`;
|
|
412
|
+
} else {
|
|
413
|
+
policyLine += `\n principal is ${entityType}`;
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
policyLine += `\n principal`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Action
|
|
420
|
+
if (Array.isArray(action)) {
|
|
421
|
+
if (action.length === 1) {
|
|
422
|
+
policyLine += `,\n action == ${formatAction(action[0])}`;
|
|
423
|
+
} else {
|
|
424
|
+
const actions = action.map(a => formatAction(a)).join(', ');
|
|
425
|
+
policyLine += `,\n action in [${actions}]`;
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
policyLine += `,\n action == ${formatAction(action)}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Resource
|
|
432
|
+
if (resource) {
|
|
433
|
+
const entityType = sanitizeIdentifier(resource.type, 'resource_type');
|
|
434
|
+
if (resource.id) {
|
|
435
|
+
policyLine += `,\n resource == ${entityType}::"${escapeCedarString(resource.id)}"`;
|
|
436
|
+
} else {
|
|
437
|
+
policyLine += `,\n resource is ${entityType}`;
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
policyLine += `,\n resource`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
policyLine += '\n)';
|
|
444
|
+
|
|
445
|
+
// When clause
|
|
446
|
+
// SECURITY: rawCondition is validated to prevent injection attacks.
|
|
447
|
+
// If validation fails, fall back to structured conditions.
|
|
448
|
+
if (rawCondition) {
|
|
449
|
+
if (isValidRawCondition(rawCondition)) {
|
|
450
|
+
policyLine += `\nwhen { ${rawCondition} };`;
|
|
451
|
+
} else if (conditions.length > 0) {
|
|
452
|
+
// Fallback to structured conditions if rawCondition is rejected
|
|
453
|
+
const conditionStr = conditions.map(c => conditionToCedar(c)).join(' && ');
|
|
454
|
+
policyLine += `\nwhen { ${conditionStr} };`;
|
|
455
|
+
} else {
|
|
456
|
+
policyLine += ';';
|
|
457
|
+
}
|
|
458
|
+
} else if (conditions.length > 0) {
|
|
459
|
+
const conditionStr = conditions.map(c => conditionToCedar(c)).join(' && ');
|
|
460
|
+
policyLine += `\nwhen { ${conditionStr} };`;
|
|
461
|
+
} else {
|
|
462
|
+
policyLine += ';';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return policyLine;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Convert a PolicyRule to Cedar policy text with proper annotations.
|
|
470
|
+
*
|
|
471
|
+
* @param rule - The PolicyRule to convert
|
|
472
|
+
* @returns Cedar policy text string
|
|
473
|
+
*
|
|
474
|
+
* @example
|
|
475
|
+
* ```typescript
|
|
476
|
+
* const rule: PolicyRule = {
|
|
477
|
+
* annotations: { id: 'rule-001', name: 'Block threats', severity: 'high' },
|
|
478
|
+
* effect: 'forbid',
|
|
479
|
+
* principal: null,
|
|
480
|
+
* action: 'call_tool',
|
|
481
|
+
* resource: null,
|
|
482
|
+
* conditions: [{ field: 'threat_count', operator: 'gt', value: 0 }],
|
|
483
|
+
* enabled: true,
|
|
484
|
+
* order: 0,
|
|
485
|
+
* };
|
|
486
|
+
*
|
|
487
|
+
* const cedar = ruleToCedar(rule);
|
|
488
|
+
* // Output:
|
|
489
|
+
* // @id("rule-001")
|
|
490
|
+
* // @name("Block threats")
|
|
491
|
+
* // @severity("high")
|
|
492
|
+
* // forbid (
|
|
493
|
+
* // principal,
|
|
494
|
+
* // action == Action::"call_tool",
|
|
495
|
+
* // resource
|
|
496
|
+
* // )
|
|
497
|
+
* // when { context.threat_count > 0 };
|
|
498
|
+
* ```
|
|
499
|
+
*/
|
|
500
|
+
export function ruleToCedar(rule: PolicyRule): string {
|
|
501
|
+
const lines: string[] = [];
|
|
502
|
+
|
|
503
|
+
// Generate Cedar annotations
|
|
504
|
+
lines.push(...generateAnnotationLines(rule.annotations, rule.customAnnotations));
|
|
505
|
+
|
|
506
|
+
// Generate policy body
|
|
507
|
+
lines.push(generatePolicyBody(
|
|
508
|
+
rule.effect,
|
|
509
|
+
rule.principal,
|
|
510
|
+
rule.action,
|
|
511
|
+
rule.resource,
|
|
512
|
+
rule.conditions,
|
|
513
|
+
rule.rawCondition
|
|
514
|
+
));
|
|
515
|
+
|
|
516
|
+
return lines.join('\n');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Convert multiple PolicyRules to Cedar policy text.
|
|
521
|
+
* Only enabled rules are included, sorted by order.
|
|
522
|
+
*
|
|
523
|
+
* @param rules - Array of PolicyRules to convert
|
|
524
|
+
* @param includeDisabled - If true, include disabled rules as comments (default: false)
|
|
525
|
+
* @returns Cedar policy text with all rules separated by blank lines
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* ```typescript
|
|
529
|
+
* const rules: PolicyRule[] = [...];
|
|
530
|
+
* const cedarText = rulesToCedar(rules);
|
|
531
|
+
* ```
|
|
532
|
+
*/
|
|
533
|
+
export function rulesToCedar(rules: PolicyRule[], includeDisabled: boolean = false): string {
|
|
534
|
+
const sortedRules = [...rules].sort((a, b) => a.order - b.order);
|
|
535
|
+
|
|
536
|
+
const cedarPolicies: string[] = [];
|
|
537
|
+
for (const rule of sortedRules) {
|
|
538
|
+
if (rule.enabled) {
|
|
539
|
+
cedarPolicies.push(ruleToCedar(rule));
|
|
540
|
+
} else if (includeDisabled) {
|
|
541
|
+
// Include disabled rules as comments
|
|
542
|
+
const cedarLines = ruleToCedar(rule).split('\n');
|
|
543
|
+
cedarPolicies.push(cedarLines.map(line => `// [DISABLED] ${line}`).join('\n'));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return cedarPolicies.join('\n\n');
|
|
548
|
+
}
|
|
549
|
+
|
|
291
550
|
/**
|
|
292
551
|
* Builder for constructing Cedar policies with type safety.
|
|
293
552
|
*/
|
package/src/index.ts
CHANGED
package/src/parser.test.ts
CHANGED
|
@@ -26,7 +26,7 @@ describe('parseCedarToRules', () => {
|
|
|
26
26
|
expect(result.unstructured).toHaveLength(0);
|
|
27
27
|
|
|
28
28
|
const rule = result.rules[0];
|
|
29
|
-
expect(rule.id).toBe('allow-read-files');
|
|
29
|
+
expect(rule.annotations.id).toBe('allow-read-files');
|
|
30
30
|
expect(rule.effect).toBe('permit');
|
|
31
31
|
expect(rule.principal).toEqual({ type: 'User' });
|
|
32
32
|
expect(rule.action).toBe('read_file');
|
|
@@ -53,7 +53,7 @@ describe('parseCedarToRules', () => {
|
|
|
53
53
|
expect(result.rules).toHaveLength(1);
|
|
54
54
|
|
|
55
55
|
const rule = result.rules[0];
|
|
56
|
-
expect(rule.id).toBe('block-high-risk');
|
|
56
|
+
expect(rule.annotations.id).toBe('block-high-risk');
|
|
57
57
|
expect(rule.effect).toBe('forbid');
|
|
58
58
|
expect(rule.action).toBe('execute_tool');
|
|
59
59
|
|
package/src/parser.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Architecture:
|
|
8
8
|
* 1. Cedar text → Cedar JSON (via cedar-wasm policyToJson)
|
|
9
|
-
* 2. Cedar JSON → PolicyRule (simple JSON mapping)
|
|
9
|
+
* 2. Cedar JSON → PolicyRule (simple JSON mapping with annotation extraction)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as cedar from "@cedar-policy/cedar-wasm/nodejs";
|
|
13
13
|
import type { PolicyRule, PolicyCondition, PolicyEntity, PolicyEffect, ConditionOperator } from "./builder.js";
|
|
14
|
+
import { parseAnnotations, generateRuleId } from "./annotations.js";
|
|
14
15
|
import { ParserError, ErrorCodes } from "./errors.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -145,10 +146,11 @@ export function parseCedarToRules(cedarText: string): ParseResult {
|
|
|
145
146
|
// Check for duplicate policy IDs and add warnings
|
|
146
147
|
const idOccurrences = new Map<string, number[]>();
|
|
147
148
|
result.rules.forEach((rule, idx) => {
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
const ruleId = rule.annotations.id;
|
|
150
|
+
if (ruleId) {
|
|
151
|
+
const indices = idOccurrences.get(ruleId) || [];
|
|
150
152
|
indices.push(idx);
|
|
151
|
-
idOccurrences.set(
|
|
153
|
+
idOccurrences.set(ruleId, indices);
|
|
152
154
|
}
|
|
153
155
|
});
|
|
154
156
|
for (const [id, indices] of idOccurrences) {
|
|
@@ -164,7 +166,7 @@ export function parseCedarToRules(cedarText: string): ParseResult {
|
|
|
164
166
|
|
|
165
167
|
/**
|
|
166
168
|
* Convert Cedar JSON policy to PolicyRule.
|
|
167
|
-
* This is pure JSON mapping -
|
|
169
|
+
* This is pure JSON mapping - uses parseAnnotations to extract structured annotations.
|
|
168
170
|
*/
|
|
169
171
|
function cedarJsonToRule(
|
|
170
172
|
policy: CedarPolicyJSON,
|
|
@@ -181,9 +183,20 @@ function cedarJsonToRule(
|
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
try {
|
|
186
|
+
// Parse annotations using the shared utility
|
|
187
|
+
const { annotations, customAnnotations } = parseAnnotations(policy.annotations);
|
|
188
|
+
|
|
189
|
+
// Ensure id and name have sensible defaults
|
|
190
|
+
if (!annotations.id) {
|
|
191
|
+
annotations.id = policyId || generateRuleId();
|
|
192
|
+
}
|
|
193
|
+
if (!annotations.name) {
|
|
194
|
+
annotations.name = annotations.id;
|
|
195
|
+
}
|
|
196
|
+
|
|
184
197
|
const rule: PolicyRule = {
|
|
185
|
-
|
|
186
|
-
|
|
198
|
+
annotations,
|
|
199
|
+
customAnnotations,
|
|
187
200
|
effect: policy.effect as PolicyEffect,
|
|
188
201
|
principal: mapScopeToEntity(policy.principal, "principal"),
|
|
189
202
|
action: mapActionScope(policy.action),
|
|
@@ -193,11 +206,6 @@ function cedarJsonToRule(
|
|
|
193
206
|
order: index,
|
|
194
207
|
};
|
|
195
208
|
|
|
196
|
-
// Map description from annotations
|
|
197
|
-
if (policy.annotations?.description) {
|
|
198
|
-
rule.description = policy.annotations.description;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
209
|
// Map conditions
|
|
202
210
|
const { conditions, rawCondition } = mapConditions(policy.conditions);
|
|
203
211
|
rule.conditions = conditions;
|