Skip to content
Star -

Policy Enforcement

Related Topics: Authentication (user context source) | Auditing (log policy decisions) | Testing (test policies) | Common Tasks (quick how-to)

MXCP’s policy engine provides fine-grained access control for your endpoints. Policies can control who can call endpoints (input policies) and what data they can see (output policies).

  1. Request arrives with user context from authentication
  2. Input policies evaluate - any deny stops processing
  3. Endpoint executes if input policies pass
  4. Output policies filter/mask the response data
  5. Response returns to client

Evaluated before endpoint execution:

  • Block unauthorized requests
  • Validate user permissions
  • Enforce business rules

Evaluated after endpoint execution:

  • Filter sensitive fields
  • Mask data values
  • Redact information

Policies are defined in endpoint YAML files:

tool:
name: employee_data
# ... parameters and return ...
policies:
input:
- condition: "user.role != 'hr' && user.role != 'hr_manager'"
action: deny
reason: "HR role required"
output:
- condition: "user.role != 'hr_manager'"
action: filter_fields
fields: ["salary", "ssn"]
reason: "Sensitive data restricted"

Conditions use a CEL-like expression syntax:

VariableTypeDescription
user.idstringUser identifier
user.emailstringUser email address
user.namestringUser display name
user.rolestringPrimary role
user.permissionsarrayList of permissions
user.groupsarrayGroup memberships

For anonymous users (when no authentication is configured), the user context defaults to:

{
"role": "anonymous",
"permissions": [],
"user_id": null,
"username": null,
"email": null,
"provider": null
}

For input policies, the CEL evaluation context contains:

  • user - User context object (see above)
  • All query parameters at the top level - Direct access to endpoint parameters

Example context for an endpoint with parameters employee_id and department:

{
"user": {
"user_id": "123",
"role": "admin",
"permissions": ["employee.read"]
},
"employee_id": "emp456",
"department": "engineering"
}

This means you can reference query parameters directly:

# Check if user is viewing their own profile
condition: "employee_id != user.user_id && user.role != 'admin'"

For output policies, the CEL evaluation context contains:

  • user - User context object
  • response - The complete response data from the endpoint

Example context for an employee endpoint response:

{
"user": {
"user_id": "123",
"role": "user",
"permissions": ["employee.read"]
},
"response": {
"id": "emp456",
"name": "John Doe",
"department": "HR",
"salary": 95000,
"ssn": "123-45-6789"
}
}

This allows policies based on response content:

# Filter salary for HR department employees viewed by non-HR users
condition: "response.department == 'HR' && user.role != 'hr_manager'"
action: filter_fields
fields: ["salary"]

Important: There is no overlap between user context and query parameters/response data because:

  1. User context is always nested under user
  2. Query parameters are available at the top level (input policies only)
  3. Response data is nested under response (output policies only)

This prevents naming conflicts. For example, if an endpoint has a parameter called role, it won’t conflict with user.role:

# This condition checks the user's role vs a query parameter
condition: "user.role == 'admin' && role == 'manager'"

Critical Security Warning: “user” Parameter Collision

Section titled “Critical Security Warning: “user” Parameter Collision”

NEVER name a query parameter “user” as this can cause a serious security vulnerability!

If you have a query parameter named user, it will be overridden by the user context during policy evaluation. While MXCP now detects and handles this collision (user context takes precedence), this can still cause confusion and potential security issues.

# BAD: Don't do this!
parameters:
- name: user # This conflicts with user context!
type: string
# GOOD: Use a different name
parameters:
- name: user_id # Clear and no collision
type: string
- name: username # Alternative naming
type: string
- name: target_user # Descriptive naming
type: string

What happens if you use “user” as a parameter name:

  • MXCP will log a warning about the collision
  • The user context will take precedence (secure behavior)
  • Your policies will work correctly, but may be confusing
  • CLI usage becomes ambiguous (--param user=... vs user context)

Best practice: Choose descriptive parameter names that don’t conflict with reserved namespaces (user, response).

The following variable names are reserved in policy evaluation contexts:

Input Policies:

  • user - User context object (always reserved)
  • Any other names are available for query parameters

Output Policies:

  • user - User context object (always reserved)
  • response - Response data object (always reserved)

Future-proofing: While only user and response are currently reserved, avoid using system-like names such as system, config, env, request, context, etc. for query parameters to prevent potential conflicts in future versions.

OperatorDescriptionExample
==Equalsuser.role == 'admin'
!=Not equalsuser.role != 'guest'
>Greater thanuser.level > 5
<Less thanuser.level < 10
>=Greater or equaluser.age >= 18
<=Less or equaluser.count <= 100
OperatorDescriptionExample
&&Anduser.role == 'admin' && user.verified
||Oruser.role == 'admin' || user.role == 'manager'
!Not!user.banned
OperationDescriptionExample
.contains()Contains substringuser.email.contains('@company.com')
.startsWith()Starts withuser.id.startsWith('EMP')
.endsWith()Ends withuser.email.endsWith('.edu')
OperationDescriptionExample
inItem in array'admin' in user.roles
allAll items matchuser.permissions.all(p, p.startsWith('read'))
existsAny item matchesuser.groups.exists(g, g == 'admins')
# Allow only admins
condition: "user.role == 'admin'"
# Allow users and admins, but not guests
condition: "user.role in ['user', 'admin']"
# Deny anonymous users
condition: "user.user_id == null"
# Check for specific permission
condition: "'employee.read' in user.permissions"
# Check for multiple permissions (AND)
condition: "'employee.read' in user.permissions && 'employee.write' in user.permissions"
# Check for any of several permissions
condition: "user.permissions.exists(p, p in ['admin', 'manager'])"
# Allow users to only query their own profile
condition: "employee_id != user.user_id && user.role != 'admin'"
action: deny
reason: "Users can only view their own profile"
# Restrict date ranges for non-admins
condition: "user.role != 'admin' && (end_date - start_date).getDays() > 30"
action: deny
reason: "Non-admins can only query up to 30 days of data"
# Filter fields based on response content
condition: "response.department == 'HR' && user.role != 'hr_manager'"
action: filter_fields
fields: ["salary", "performance_rating"]
# Mask PII for non-privileged users
condition: "!('pii.view' in user.permissions)"
action: mask_fields
fields: ["ssn", "phone", "address"]

Block the request entirely:

input:
- condition: "!('data.read' in user.permissions)"
action: deny
reason: "Missing data.read permission"

Note: For logging access without blocking, use audit logging instead of policies. See Auditing for details on tracking all access events.

Block the response based on its content:

output:
- condition: "response.email.endsWith('@sensitive.com')"
action: deny
reason: "Emails from sensitive.com must not be exposed"

This is useful when you can’t determine access until after seeing the data.

Remove specific fields from the response:

output:
- condition: "user.role != 'admin'"
action: filter_fields
fields: ["ssn", "salary", "internal_notes"]
reason: "Restricted to admins"

Remove all fields marked as sensitive: true:

output:
- condition: "!('pii.view' in user.permissions)"
action: filter_sensitive_fields
reason: "PII access not authorized"

Note: This action requires fields to be marked with sensitive: true in your return type schema. See Data Sensitivity Levels for an example.

Replace field values with "****":

output:
- condition: "user.role == 'support'"
action: mask_fields
fields: ["email", "phone"]
reason: "Data masked for support role"

Understanding how field operations work with different data structures:

When filtering or masking fields that don’t exist in the response, MXCP handles them gracefully:

  • filter_fields: Non-existent fields are silently ignored (no error)
  • mask_fields: Non-existent fields are silently ignored (no error)
  • filter_sensitive_fields: Only filters fields that both exist AND are marked sensitive

This allows you to define broad policies that work across endpoints with different schemas:

output:
# This won't error even if some fields don't exist
- condition: "user.role == 'basic'"
action: filter_fields
fields: ["ssn", "salary", "credit_score", "internal_id"]
reason: "Restricted fields for basic users"

When the response is an array of objects, field filtering/masking is applied to each item:

# Response: [{"name": "Alice", "ssn": "123"}, {"name": "Bob", "ssn": "456"}]
# After filter_fields: [{"name": "Alice"}, {"name": "Bob"}]
output:
- condition: "user.role != 'admin'"
action: filter_fields
fields: ["ssn"]

Field operations currently apply to top-level fields only. For nested objects, the entire nested object can be filtered:

# Response: {"name": "Alice", "address": {"street": "123 Main", "city": "NYC"}}
output:
# This removes the entire address object
- condition: "user.role == 'public'"
action: filter_fields
fields: ["address"]

When multiple output policies match, they are applied in order. Each policy’s action modifies the response for subsequent policies:

output:
# First: mask email for non-admins (replaced with "****")
- condition: "user.role != 'admin'"
action: mask_fields
fields: ["email"]
# Second: remove salary for non-finance (applied to already-masked response)
- condition: "user.department != 'finance'"
action: filter_fields
fields: ["salary"]
tool:
name: financial_report
description: Generate financial reports
policies:
input:
# Only finance team can access
- condition: "user.role != 'finance' && user.role != 'executive'"
action: deny
reason: "Finance or executive role required"
output:
# Non-executives see summary only
- condition: "user.role != 'executive'"
action: filter_fields
fields: ["detailed_breakdown", "individual_salaries"]
reason: "Detailed data restricted to executives"
tool:
name: customer_data
description: Access customer information
policies:
input:
# Require base permission
- condition: "!('customer.read' in user.permissions)"
action: deny
reason: "Missing customer.read permission"
output:
# Filter PII without special permission
- condition: "!('customer.pii' in user.permissions)"
action: filter_fields
fields: ["email", "phone", "address"]
reason: "PII access requires customer.pii permission"
# Additional filter for financial data
- condition: "!('customer.financial' in user.permissions)"
action: filter_fields
fields: ["credit_card", "bank_account", "credit_score"]
reason: "Financial data requires customer.financial permission"
tool:
name: employee_record
return:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
sensitive: true
salary:
type: number
sensitive: true
ssn:
type: string
sensitive: true
department:
type: string
policies:
input:
- condition: "user.role == 'guest'"
action: deny
reason: "Guests cannot access employee records"
output:
# Remove all sensitive fields for non-HR
- condition: "user.role != 'hr'"
action: filter_sensitive_fields
reason: "HR role required for sensitive data"
tool:
name: project_details
policies:
output:
# Team members see their own projects only
- condition: "user.role == 'member' && !response.team_members.contains(user.id)"
action: deny
reason: "Can only view your own projects"
# Contractors see limited info
- condition: "user.type == 'contractor'"
action: filter_fields
fields: ["budget", "internal_roadmap", "client_contacts"]
reason: "Contractor access limited"

When running MXCP in server mode with authentication enabled, the user context is automatically populated from the OAuth token:

Terminal window
mxcp serve --profile production

The auth middleware extracts user information and makes it available to policies.

For command-line execution, provide user context manually with --user-context.

The test command supports user context in test definitions via user_context: field. Tests can validate both policy enforcement and filtered output.

Test with simulated user context:

Terminal window
# Test as regular user
mxcp run tool employee_data \
--param employee_id=123 \
--user-context '{"role": "user", "permissions": ["data.read"]}'
# Test as admin
mxcp run tool employee_data \
--param employee_id=123 \
--user-context '{"role": "admin", "permissions": ["data.read", "pii.view"]}'
# Test denied access
mxcp run tool employee_data \
--param employee_id=123 \
--user-context '{"role": "guest"}'
# Load user context from file
mxcp run tool employee_data \
--param employee_id=123 \
--user-context @user_context.json

Example user_context.json:

{
"user_id": "456",
"role": "admin",
"permissions": ["employee.read", "pii.view"]
}

Add policy tests to your endpoint:

tool:
name: sensitive_tool
tests:
- name: admin_full_access
description: Admin sees all fields
arguments:
- key: id
value: 1
user_context:
role: admin
permissions: ["all"]
result_contains:
salary: 85000
ssn: "123-45-6789"
- name: user_filtered_access
description: Regular user sees filtered data
arguments:
- key: id
value: 1
user_context:
role: user
permissions: ["data.read"]
result_not_contains:
- salary
- ssn

Note: Policy denial tests cannot be directly tested via YAML test assertions. Use CLI testing with --user-context to verify deny policies work correctly.

Policies are evaluated in order:

  1. Input policies - Top to bottom

    • First deny stops execution immediately
    • Remaining policies are skipped
  2. Endpoint execution - Only if all input policies pass

  3. Output policies - Top to bottom

    • All matching policies applied
    • Fields filtered/masked cumulatively

Access user context in SQL queries when running through mxcp serve:

SELECT *
FROM data
WHERE
created_by = get_username()
OR get_user_provider() = 'admin'

Available functions:

  • get_username() - User’s display name
  • get_user_email() - User’s email address
  • get_user_provider() - OAuth provider name (github, google, etc.)
  • get_user_external_token() - OAuth provider token for API calls
  • get_request_header(name) - Get a specific HTTP request header
  • get_request_headers_json() - Get all request headers as JSON

Note: These functions are only available when endpoints are executed through mxcp serve. They return NULL when run via mxcp run.

Default to denying access when in doubt:

# Good: Explicitly allow known roles
input:
- condition: "!(user.role in ['admin', 'manager', 'user'])"
action: deny
reason: "Unknown role - access denied"
# Avoid: Only denying specific roles (might miss new roles)
input:
- condition: "user.role == 'guest'"
action: deny
reason: "Guests not allowed"

Require specific permissions:

input:
- condition: "!('data.read' in user.permissions)"
action: deny
reason: "data.read permission required"

Provide helpful error messages:

# Good
reason: "Finance role required. Contact your manager for access."
# Avoid
reason: "Denied"

Use multiple policies for clarity:

input:
# Authentication check
- condition: "user.id == ''"
action: deny
reason: "Authentication required"
# Role check
- condition: "user.role == 'guest'"
action: deny
reason: "Guest access not allowed"
# Permission check
- condition: "!('data.read' in user.permissions)"
action: deny
reason: "Missing data.read permission"

Mark sensitive fields in schema:

return:
type: object
properties:
ssn:
type: string
sensitive: true # Easy to filter with filter_sensitive_fields

Test all user roles and edge cases.

Dynamic Field Filtering Based on Relationship

Section titled “Dynamic Field Filtering Based on Relationship”

Filter data based on user relationships:

output:
# Users can see full details of their direct reports
- condition: |
user.role == 'manager' &&
!response.exists(r, r.manager_id == user.user_id)
action: filter_fields
fields: ["salary", "performance_rating", "personal_goals"]

Restrict access during off-hours:

input:
# Restrict access during off-hours for non-admins
- condition: |
user.role != 'admin' &&
(timestamp.now().getHours() < 8 || timestamp.now().getHours() > 18)
action: deny
reason: "Access restricted to business hours (8 AM - 6 PM)"

Mask data based on multiple conditions:

output:
# Mask data based on security clearance (all masked to "****")
- condition: |
response.security_clearance > user.security_clearance ||
(response.classified && !('classified.view' in user.permissions))
action: mask_fields
fields: ["details", "location", "contacts"]
  1. Check that the endpoint YAML has valid syntax
  2. Verify the condition expression is valid CEL
  3. Check logs for policy evaluation errors
  4. Ensure user context is being passed correctly

Common issues:

  • String comparisons are case-sensitive
  • Use in for list membership, not contains
  • Null checks should use == null, not !exists
  • Check condition syntax
  • Verify user context fields exist
  • Test with debug mode
  • Verify field name matches exactly
  • Check policy condition evaluates correctly
  • Ensure policy order is correct
  • Review policy conditions
  • Check user context values
  • Use --debug flag
  • Keep CEL expressions simple for better performance
  • Filter fields at the output stage rather than fetching and then denying
  • Consider caching policy evaluation results for repeated queries