@highflame/policy 1.1.3 → 1.2.1
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/actions.gen.d.ts +21 -0
- package/dist/actions.gen.d.ts.map +1 -1
- package/dist/actions.gen.js +21 -0
- package/dist/actions.gen.js.map +1 -1
- package/dist/builder.d.ts +47 -10
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js.map +1 -1
- package/dist/engine.d.ts +37 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +99 -0
- package/dist/engine.js.map +1 -1
- package/dist/engine.test.d.ts +8 -0
- package/dist/engine.test.d.ts.map +1 -0
- package/dist/engine.test.js +190 -0
- package/dist/engine.test.js.map +1 -0
- package/dist/entities.gen.d.ts +4 -0
- package/dist/entities.gen.d.ts.map +1 -1
- package/dist/entities.gen.js +4 -0
- package/dist/entities.gen.js.map +1 -1
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +127 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts +34 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +393 -0
- package/dist/parser.js.map +1 -0
- package/dist/parser.test.d.ts +8 -0
- package/dist/parser.test.d.ts.map +1 -0
- package/dist/parser.test.js +143 -0
- package/dist/parser.test.js.map +1 -0
- package/dist/schema.gen.d.ts +1 -1
- package/dist/schema.gen.d.ts.map +1 -1
- package/dist/schema.gen.js +331 -17
- package/dist/schema.gen.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 +8 -2
- package/src/actions.gen.ts +21 -0
- package/src/builder.ts +52 -10
- package/src/engine.test.ts +371 -0
- package/src/engine.ts +145 -0
- package/src/entities.gen.ts +4 -0
- package/src/errors.ts +195 -0
- package/src/index.ts +2 -0
- package/src/parser.test.ts +169 -0
- package/src/parser.ts +517 -0
- package/src/schema.gen.ts +331 -17
- package/src/types.ts +3 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser error types and codes for highflame-policy.
|
|
3
|
+
*
|
|
4
|
+
* This module provides standardized error codes that are consistent
|
|
5
|
+
* across all language implementations (Rust, Go, TypeScript, Python).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Error codes for parser errors.
|
|
10
|
+
*
|
|
11
|
+
* These codes are stable and consistent across all language implementations.
|
|
12
|
+
* Format: HFP-<CATEGORY>-<NUMBER>
|
|
13
|
+
* - HFP = HighFlame Policy
|
|
14
|
+
* - CATEGORY = SCOPE | ACTION | COND | PARSE
|
|
15
|
+
* - NUMBER = 3-digit incremental
|
|
16
|
+
*/
|
|
17
|
+
export const ErrorCodes = {
|
|
18
|
+
// Scope constraint errors (HFP-SCOPE-xxx)
|
|
19
|
+
/** Scope constraint is missing an entity (for == operator) */
|
|
20
|
+
SCOPE_MISSING_ENTITY: "HFP-SCOPE-001",
|
|
21
|
+
/** Scope constraint is missing an entity type (for is operator) */
|
|
22
|
+
SCOPE_MISSING_ENTITY_TYPE: "HFP-SCOPE-002",
|
|
23
|
+
/** Scope constraint is missing entity list (for in operator) */
|
|
24
|
+
SCOPE_MISSING_ENTITY_LIST: "HFP-SCOPE-003",
|
|
25
|
+
/** Slot constraints are not supported in PolicyRule */
|
|
26
|
+
SCOPE_SLOT_NOT_SUPPORTED: "HFP-SCOPE-004",
|
|
27
|
+
/** Unsupported scope operator */
|
|
28
|
+
SCOPE_UNSUPPORTED_OP: "HFP-SCOPE-005",
|
|
29
|
+
|
|
30
|
+
// Action constraint errors (HFP-ACTION-xxx)
|
|
31
|
+
/** Action constraint is missing an entity (for == operator) */
|
|
32
|
+
ACTION_MISSING_ENTITY: "HFP-ACTION-001",
|
|
33
|
+
/** Action constraint is missing entities (for in operator) */
|
|
34
|
+
ACTION_MISSING_ENTITIES: "HFP-ACTION-002",
|
|
35
|
+
/** Unsupported action operator */
|
|
36
|
+
ACTION_UNSUPPORTED_OP: "HFP-ACTION-003",
|
|
37
|
+
/** Action scope is null/nil */
|
|
38
|
+
ACTION_SCOPE_NIL: "HFP-ACTION-004",
|
|
39
|
+
|
|
40
|
+
// Condition errors (HFP-COND-xxx)
|
|
41
|
+
/** Unless clauses are not supported in PolicyRule */
|
|
42
|
+
COND_UNLESS_NOT_SUPPORTED: "HFP-COND-001",
|
|
43
|
+
/** Complex condition cannot be parsed */
|
|
44
|
+
COND_COMPLEX_EXPRESSION: "HFP-COND-002",
|
|
45
|
+
|
|
46
|
+
// Parse errors (HFP-PARSE-xxx)
|
|
47
|
+
/** Invalid Cedar syntax */
|
|
48
|
+
PARSE_INVALID_SYNTAX: "HFP-PARSE-001",
|
|
49
|
+
/** Failed to convert policy to JSON */
|
|
50
|
+
PARSE_JSON_CONVERSION: "HFP-PARSE-002",
|
|
51
|
+
/** Failed to parse Cedar JSON structure */
|
|
52
|
+
PARSE_JSON_STRUCTURE: "HFP-PARSE-003",
|
|
53
|
+
/** Unknown policy effect (not permit or forbid) */
|
|
54
|
+
PARSE_UNKNOWN_EFFECT: "HFP-PARSE-004",
|
|
55
|
+
/** Duplicate policy ID found */
|
|
56
|
+
PARSE_DUPLICATE_ID: "HFP-PARSE-005",
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Context information for parser errors.
|
|
63
|
+
*/
|
|
64
|
+
export interface ErrorContext {
|
|
65
|
+
/** The operator that caused the error (e.g., "==", "in", "is") */
|
|
66
|
+
operator?: string;
|
|
67
|
+
/** The field that caused the error (e.g., "principal", "action", "resource") */
|
|
68
|
+
field?: string;
|
|
69
|
+
/** The policy ID if available */
|
|
70
|
+
policyId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A structured parser error with code, message, and optional context.
|
|
75
|
+
*/
|
|
76
|
+
export class ParserError extends Error {
|
|
77
|
+
/** Machine-readable error code (e.g., "HFP-SCOPE-001") */
|
|
78
|
+
public readonly code: ErrorCode;
|
|
79
|
+
/** Optional context for debugging */
|
|
80
|
+
public readonly context?: ErrorContext;
|
|
81
|
+
|
|
82
|
+
constructor(code: ErrorCode, message: string, context?: ErrorContext) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = "ParserError";
|
|
85
|
+
this.code = code;
|
|
86
|
+
this.context = context;
|
|
87
|
+
|
|
88
|
+
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
89
|
+
const ErrorWithCapture = Error as typeof Error & { captureStackTrace?: (err: Error, constructor: Function) => void };
|
|
90
|
+
if (ErrorWithCapture.captureStackTrace) {
|
|
91
|
+
ErrorWithCapture.captureStackTrace(this, ParserError);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns a string representation including the error code.
|
|
97
|
+
*/
|
|
98
|
+
override toString(): string {
|
|
99
|
+
return `[${this.code}] ${this.message}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Serializes the error to a plain object for JSON serialization.
|
|
104
|
+
*/
|
|
105
|
+
toJSON(): { code: string; message: string; context?: ErrorContext } {
|
|
106
|
+
return {
|
|
107
|
+
code: this.code,
|
|
108
|
+
message: this.message,
|
|
109
|
+
...(this.context && { context: this.context }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Convenience static factory methods for common errors
|
|
114
|
+
|
|
115
|
+
/** Scope constraint is missing an entity */
|
|
116
|
+
static scopeMissingEntity(operator: string, field: string): ParserError {
|
|
117
|
+
return new ParserError(
|
|
118
|
+
ErrorCodes.SCOPE_MISSING_ENTITY,
|
|
119
|
+
`'${operator}' constraint is missing an entity`,
|
|
120
|
+
{ operator, field }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Scope constraint is missing an entity type */
|
|
125
|
+
static scopeMissingEntityType(field: string): ParserError {
|
|
126
|
+
return new ParserError(
|
|
127
|
+
ErrorCodes.SCOPE_MISSING_ENTITY_TYPE,
|
|
128
|
+
"'is' constraint is missing an entity_type",
|
|
129
|
+
{ operator: "is", field }
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Scope constraint is missing entity list */
|
|
134
|
+
static scopeMissingEntityList(field: string): ParserError {
|
|
135
|
+
return new ParserError(
|
|
136
|
+
ErrorCodes.SCOPE_MISSING_ENTITY_LIST,
|
|
137
|
+
"'in' constraint is missing an entity",
|
|
138
|
+
{ operator: "in", field }
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Slot constraints are not supported */
|
|
143
|
+
static scopeSlotNotSupported(operator: string, field: string): ParserError {
|
|
144
|
+
return new ParserError(
|
|
145
|
+
ErrorCodes.SCOPE_SLOT_NOT_SUPPORTED,
|
|
146
|
+
`'${operator}' constraint with slot cannot be represented`,
|
|
147
|
+
{ operator, field }
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Unsupported scope operator */
|
|
152
|
+
static scopeUnsupportedOp(operator: string, field: string): ParserError {
|
|
153
|
+
return new ParserError(
|
|
154
|
+
ErrorCodes.SCOPE_UNSUPPORTED_OP,
|
|
155
|
+
`Unsupported scope operator: ${operator}`,
|
|
156
|
+
{ operator, field }
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Action constraint is missing an entity */
|
|
161
|
+
static actionMissingEntity(operator: string): ParserError {
|
|
162
|
+
return new ParserError(
|
|
163
|
+
ErrorCodes.ACTION_MISSING_ENTITY,
|
|
164
|
+
`Action '${operator}' constraint is missing an entity`,
|
|
165
|
+
{ operator, field: "action" }
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Action constraint is missing entities */
|
|
170
|
+
static actionMissingEntities(): ParserError {
|
|
171
|
+
return new ParserError(
|
|
172
|
+
ErrorCodes.ACTION_MISSING_ENTITIES,
|
|
173
|
+
"Action 'in' constraint is missing entities",
|
|
174
|
+
{ operator: "in", field: "action" }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Unsupported action operator */
|
|
179
|
+
static actionUnsupportedOp(operator: string): ParserError {
|
|
180
|
+
return new ParserError(
|
|
181
|
+
ErrorCodes.ACTION_UNSUPPORTED_OP,
|
|
182
|
+
`Unsupported action operator: ${operator}`,
|
|
183
|
+
{ operator, field: "action" }
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Action scope is nil */
|
|
188
|
+
static actionScopeNil(): ParserError {
|
|
189
|
+
return new ParserError(
|
|
190
|
+
ErrorCodes.ACTION_SCOPE_NIL,
|
|
191
|
+
"Action scope is nil",
|
|
192
|
+
{ field: "action" }
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser unit tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the Cedar text → PolicyRule conversion using the official Cedar engine.
|
|
5
|
+
* These tests demonstrate how a client like highflame-authz would use the parser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { parseCedarToRules } from './parser.js';
|
|
10
|
+
|
|
11
|
+
describe('parseCedarToRules', () => {
|
|
12
|
+
it('should parse a simple permit policy', () => {
|
|
13
|
+
const cedarText = `
|
|
14
|
+
@id("allow-read-files")
|
|
15
|
+
permit(
|
|
16
|
+
principal is User,
|
|
17
|
+
action == Action::"read_file",
|
|
18
|
+
resource is FilePath
|
|
19
|
+
);
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const result = parseCedarToRules(cedarText);
|
|
23
|
+
|
|
24
|
+
expect(result.errors).toHaveLength(0);
|
|
25
|
+
expect(result.rules).toHaveLength(1);
|
|
26
|
+
expect(result.unstructured).toHaveLength(0);
|
|
27
|
+
|
|
28
|
+
const rule = result.rules[0];
|
|
29
|
+
expect(rule.id).toBe('allow-read-files');
|
|
30
|
+
expect(rule.effect).toBe('permit');
|
|
31
|
+
expect(rule.principal).toEqual({ type: 'User' });
|
|
32
|
+
expect(rule.action).toBe('read_file');
|
|
33
|
+
expect(rule.resource).toEqual({ type: 'FilePath' });
|
|
34
|
+
expect(rule.enabled).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should parse a policy with when conditions', () => {
|
|
38
|
+
const cedarText = `
|
|
39
|
+
@id("block-high-risk")
|
|
40
|
+
forbid(
|
|
41
|
+
principal,
|
|
42
|
+
action == Action::"execute_tool",
|
|
43
|
+
resource
|
|
44
|
+
)
|
|
45
|
+
when {
|
|
46
|
+
context.threat_level == "high"
|
|
47
|
+
};
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const result = parseCedarToRules(cedarText);
|
|
51
|
+
|
|
52
|
+
expect(result.errors).toHaveLength(0);
|
|
53
|
+
expect(result.rules).toHaveLength(1);
|
|
54
|
+
|
|
55
|
+
const rule = result.rules[0];
|
|
56
|
+
expect(rule.id).toBe('block-high-risk');
|
|
57
|
+
expect(rule.effect).toBe('forbid');
|
|
58
|
+
expect(rule.action).toBe('execute_tool');
|
|
59
|
+
|
|
60
|
+
// Check condition was parsed
|
|
61
|
+
expect(rule.conditions.length).toBeGreaterThanOrEqual(0);
|
|
62
|
+
// If condition parsing works, it should have the condition
|
|
63
|
+
// If not, it should be in rawCondition
|
|
64
|
+
if (rule.conditions.length > 0) {
|
|
65
|
+
expect(rule.conditions[0].field).toBe('threat_level');
|
|
66
|
+
expect(rule.conditions[0].operator).toBe('eq');
|
|
67
|
+
expect(rule.conditions[0].value).toBe('high');
|
|
68
|
+
} else {
|
|
69
|
+
expect(rule.rawCondition).toBeDefined();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return errors for invalid Cedar syntax', () => {
|
|
74
|
+
const invalidCedar = `
|
|
75
|
+
permit(
|
|
76
|
+
principal is User
|
|
77
|
+
// missing comma and rest of policy
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const result = parseCedarToRules(invalidCedar);
|
|
81
|
+
|
|
82
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should handle multiple policies', () => {
|
|
86
|
+
const cedarText = `
|
|
87
|
+
@id("rule-1")
|
|
88
|
+
permit(principal, action == Action::"read", resource);
|
|
89
|
+
|
|
90
|
+
@id("rule-2")
|
|
91
|
+
forbid(principal, action == Action::"delete", resource);
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
const result = parseCedarToRules(cedarText);
|
|
95
|
+
|
|
96
|
+
expect(result.errors).toHaveLength(0);
|
|
97
|
+
expect(result.rules).toHaveLength(2);
|
|
98
|
+
expect(result.rules[0].effect).toBe('permit');
|
|
99
|
+
expect(result.rules[1].effect).toBe('forbid');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should put policies with unless clauses in unstructured', () => {
|
|
103
|
+
const cedarText = `
|
|
104
|
+
permit(principal, action, resource)
|
|
105
|
+
unless {
|
|
106
|
+
context.is_blocked == true
|
|
107
|
+
};
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const result = parseCedarToRules(cedarText);
|
|
111
|
+
|
|
112
|
+
// Unless clauses can't be represented as PolicyRule
|
|
113
|
+
expect(result.rules).toHaveLength(0);
|
|
114
|
+
expect(result.unstructured.length).toBeGreaterThan(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should store complex conditions as valid JSON array in rawCondition', () => {
|
|
118
|
+
// Use a condition with boolean AND that can't be mapped to structured format
|
|
119
|
+
const cedarText = `
|
|
120
|
+
@id("complex-condition")
|
|
121
|
+
permit(principal, action, resource)
|
|
122
|
+
when {
|
|
123
|
+
context.a == "x" && context.b == "y"
|
|
124
|
+
};
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
const result = parseCedarToRules(cedarText);
|
|
128
|
+
|
|
129
|
+
expect(result.errors).toHaveLength(0);
|
|
130
|
+
expect(result.rules).toHaveLength(1);
|
|
131
|
+
|
|
132
|
+
const rule = result.rules[0];
|
|
133
|
+
|
|
134
|
+
// The complex && condition should be in rawCondition as valid JSON array
|
|
135
|
+
if (rule.rawCondition) {
|
|
136
|
+
// Verify it's valid JSON (should not throw)
|
|
137
|
+
const parsed = JSON.parse(rule.rawCondition);
|
|
138
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
139
|
+
expect(parsed.length).toBeGreaterThan(0);
|
|
140
|
+
}
|
|
141
|
+
// Either conditions were mapped or rawCondition contains valid JSON
|
|
142
|
+
expect(rule.conditions.length > 0 || rule.rawCondition).toBeTruthy();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should warn about duplicate policy IDs', () => {
|
|
146
|
+
const cedarText = `
|
|
147
|
+
@id("duplicate-id")
|
|
148
|
+
permit(principal, action, resource);
|
|
149
|
+
|
|
150
|
+
@id("unique-id")
|
|
151
|
+
forbid(principal, action, resource);
|
|
152
|
+
|
|
153
|
+
@id("duplicate-id")
|
|
154
|
+
permit(principal is User, action, resource);
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const result = parseCedarToRules(cedarText);
|
|
158
|
+
|
|
159
|
+
// All policies should still be parsed
|
|
160
|
+
expect(result.rules).toHaveLength(3);
|
|
161
|
+
|
|
162
|
+
// Should have a warning about duplicate ID
|
|
163
|
+
const duplicateError = result.errors.find(e => e.includes("HFP-PARSE-005"));
|
|
164
|
+
expect(duplicateError).toBeDefined();
|
|
165
|
+
expect(duplicateError).toContain("duplicate-id");
|
|
166
|
+
expect(duplicateError).toContain("[0"); // First occurrence at index 0
|
|
167
|
+
expect(duplicateError).toContain("2"); // Second occurrence at index 2
|
|
168
|
+
});
|
|
169
|
+
});
|