This document describes the security enforcement system in Dyre, which allows fine-grained permission control over endpoint and field access with flexible denial behaviors.
The security enforcement system provides:
- Backward compatibility with existing code
- Field-level permissions with inheritance from endpoints
- Flexible denial behavior: error on deny or silent omission
- Wildcard support for universal access
- Pluggable permission checking via host application integration
- Consistent metadata across SQL, TypeScript, and field lists
- Preserve backwards compatibility with existing
securityentries - Allow schema authors to opt into field omission (
onDeny: "omit") instead of hard failures - Give host applications a clear hook for wiring their permission systems into the transpiler
- Ensure generated SQL, TypeScript metadata, and runtime results stay internally consistent when columns are omitted
security accepts three formats: string shorthand, array, or object with explicit behavior.
| Form | Example | Meaning |
|---|---|---|
| String | "customers:read" |
Require one permission; error on deny. |
| Array | ["customers:view", "customers:edit"] |
Require all listed permissions; error on deny. |
| Object | {"permissions": ["customers:email:view"], "onDeny": "omit"} |
Require all permissions; omit on deny. |
The values provided should originate from the host application's role or permission catalogue; Dyre does not impose additional namespacing or prefixes.
{
"name": "Customers",
"tableName": "dbo.Customers",
"security": {
"permissions": ["customers:read"],
"onDeny": "error"
},
"fields": [
{
"name": "CustomerID",
"nullable": false,
"security": "customers:customerid:read"
},
{
"name": "Email",
"security": {
"permissions": ["customers:email:view", "customers:email:manage"],
"onDeny": "omit"
}
},
{
"name": "Notes",
"security": {
"permissions": ["*"],
"onDeny": "omit"
}
},
"CreatedAt"
]
}permissionsis a non-empty array of host-defined role or permission identifiers (e.g.,"customers:read"). Avoid redundant prefixes; reuse the exact tokens enforced by your auth layer.- The literal
"*"acts as a catch-all and always evaluates to allowed without involving the checker. Use it for fields that inherit access from broader roles while keeping consistent metadata. onDenydefaults to"error"; setting"omit"causes unauthorized columns to be skipped where possible.- String and array shorthand are internally normalised to
{ permissions: [...], onDeny: "error" }.
String Shorthand (Single Permission)
{
"security": "customers:read"
}Array Format (Multiple Permissions, Error on Deny)
{
"security": ["customers:read", "customers:audit"]
}Object Format (Omit on Deny)
{
"security": {
"permissions": ["customers:email:view"],
"onDeny": "omit"
}
}Wildcard (Always Allowed)
{
"security": {
"permissions": ["*"],
"onDeny": "omit"
}
}The transpiler accepts an optional checker supplied by the host service:
type SecurityChecker interface {
Allow(required []string) (bool, error)
}
func NewWithSecurity(query string, ep *endpoint.Endpoint, checker SecurityChecker) (*PrimaryIR, error)Allowreturns(true, nil)when allrequiredpermissions are granted.- Returning
(false, nil)signals missing permissions. - Returning an error aborts evaluation immediately (e.g., upstream auth failure).
- A
nilchecker preserves the current permissive behaviour.
StaticChecker: Checks against a fixed set of permissions
checker := endpoint.NewStaticChecker(map[string]struct{}{
"customers:read": {},
"customers:customerid:view": {},
})RoleChecker: Custom logic via callback (e.g., admin role expansion)
checker := endpoint.NewRoleChecker(func(required []string) (bool, error) {
if userHasRole("admin") {
return true, nil // Admin bypasses all checks
}
return userHasPermissions(required), nil
})PermissiveChecker: Allows all permissions (testing/migration)
checker := endpoint.NewPermissiveChecker()- Normalise endpoint security metadata to a
SecurityPolicystruct. - If the policy contains
"*", treat it as satisfied and skip the checker. - Otherwise, before parsing SQL, probe the checker with the endpoint's permissions.
- If denied and
onDeny == "error", return a descriptive authorization error. - If denied and
onDeny == "omit", return an empty result placeholder (caller decides how to surface).
- Each time a column (or expression alias) is about to be added to the select list, consult the field policy. The presence of
"*"marks the policy as satisfied without a checker call. - If a column lacks its own policy, inherit the endpoint policy.
- When denied:
- If
onDeny == "omit": Skip appending the select statement and record the omission soFieldNames()stays consistent. - If
onDeny == "error": Bubble up an authorization error immediately.
- If
- The
SecurityCheckeris responsible for expanding higher-level roles (e.g.,admin) into the granular identifiers referenced by endpoints and fields. - You can return
truefromAllowwhen a caller holds an aggregated permission that covers the requested identifiers. Example: treatadminas satisfying every permission undercustomers:*. - Use the
"*"policy entry when you want the schema itself to mark a resource as universally accessible (or already handled upstream).
// Create a checker with granted permissions
checker := endpoint.NewStaticChecker(map[string]struct{}{
"customers:read": {},
"customers:customerid:view": {},
})
// Create IR with security enforcement
ir, err := transpiler.NewWithSecurity("CustomerID:Email:", customersEndpoint, checker)
if err != nil {
// Handle permission denied error
}
sql, err := ir.EvaluateQuery()
// SQL only includes columns the user has permission to accesschecker := endpoint.NewStaticChecker(map[string]struct{}{
"customers:read": {},
"customers:customerid:read": {},
})
ir, err := transpiler.NewWithSecurity("CustomerID:", customersEndpoint, checker)
sql, err := ir.EvaluateQuery()
// SELECT Customers.[CustomerID] FROM dbo.Customerschecker := endpoint.NewStaticChecker(map[string]struct{}{}) // caller has no grants
ir, _ := transpiler.NewWithSecurity("Email:, CreatedAt:", customersEndpoint, checker)
sql, _ := ir.EvaluateQuery()
// SELECT Customers.[CreatedAt] FROM dbo.Customers
// Email was annotated with onDeny == "omit" and is dropped.checker := endpoint.NewStaticChecker(map[string]struct{}{})
_, err := transpiler.NewWithSecurity("CustomerID:", customersEndpoint, checker)
// err => "permission denied: requires [customers:read]"checker := endpoint.NewRoleChecker(func(required []string) (bool, error) {
if userHasRole("admin") {
return true, nil // admin satisfies every requirement
}
return userHasPermissions(required), nil
})
ir, err := transpiler.NewWithSecurity(query, ep, checker)Allowreturnstruefor any requested identifiers when the caller has theadminrole.- Omitted columns still honour their
onDenysetting when the checker declines a request.
// Nil checker maintains pre-security behavior (allows everything)
ir, err := transpiler.New(query, endpoint)
// Or explicitly:
ir, err := transpiler.NewWithSecurity(query, endpoint, nil)Security enforcement automatically propagates to joined tables:
checker := endpoint.NewStaticChecker(map[string]struct{}{
"customers:read": {},
"invoices:read": {},
"invoices:amount:view": {},
})
// Create IR with security
ir, err := transpiler.NewWithSecurity("CustomerID:", customersEp, checker)
// Join Invoices - security checker is automatically propagated
joinIR := ir.INNERJOIN("Invoices").ON("CustomerID", "CustomerID")
_, err = joinIR.Query("InvoiceID:Amount:")
// If user lacks "invoices:read", join creation fails
// If user lacks "invoices:amount:view", Amount field is omitted based on its onDeny policy- SecurityPolicy struct: Normalizes security metadata with
Permissions []stringandOnDeny stringfields - NormalizeSecurityValue(): Converts string, array, or object security values to SecurityPolicy
- SecurityChecker interface: Defines
Allow(required []string) (bool, error)for runtime permission checks
- Endpoint.Security: Uses
*SecurityPolicyinstead of[]string - Field.Security: Uses
*SecurityPolicyinstead of[]string - JSON parsing: Handles all three formats (string, array, object)
- JSON output: Maintains backward-compatible array format for
onDeny="error", uses object format foronDeny="omit"
Endpoint-Level Security
NewWithSecurity()creates IR with security checker- Checks endpoint permissions before parsing query
- Returns error or empty result based on
onDenysetting - Handles wildcard
"*"permission (always allowed)
Field-Level Security
checkFieldSecurity()validates field access during column evaluation- Fields inherit endpoint security if they lack their own policy
- Omitted fields are tracked in
IR.omittedFieldsmap - Fields with
onDeny="omit"are silently excluded from SQL - Fields with
onDeny="error"cause authorization errors
- FieldNames(): Automatically reflects omissions (returns actual SQL select list)
- TypeScript generation: Shows full schema (represents available fields, not user-specific view)
- Join propagation: Security checker automatically propagates from parent IR to all joined SubIRs, ensuring consistent enforcement across the entire query tree
- Expressions referencing unauthorized fields should error, even under
onDeny == "omit", to avoid emitting partially valid SQL. This behaviour may be revisited if downstream needs differ.
- Phase 1: Deploy with nil checker (no behavior change)
- Phase 2: Add security metadata to endpoint JSON
- Phase 3: Implement SecurityChecker in host application
- Phase 4: Pass checker to NewWithSecurity()
- String format normalization
- Array format normalization
- Object format with error/omit behaviors
- Default onDeny value
- Wildcard detection
- Invalid input handling
- All three checker implementations
- Endpoint-level denial with error
- Endpoint-level denial with omit
- Field omission (onDeny="omit")
- Field denial (onDeny="error")
- Security inheritance from endpoint
- Wildcard always allowed
- Nil checker (permissive backward compatibility)
- PermissiveChecker and RoleChecker
- FieldNames() reflects omissions
- Join security propagation (checker passed to joined tables)
- Joined endpoint access denial
- Field omission in joined tables
- Nil checker in joins
All tests passing:
- endpoint package: 15 tests (including 12 new security tests)
- transpiler package: 29 tests (including 14 new security tests, 4 for join security)
- parser, lexer packages: All existing tests pass
- No breaking changes to existing functionality
-
Endpoint-level omit behavior: Returns empty IR, which generates minimal SQL. Confirmed as acceptable.
-
ORDER BY / GROUP BY with omitted columns: Currently allowed to propagate, may reference unavailable columns. Consider adding validation in future if needed.