Skip to main content

Overview

Formal policies can enforce different actions at three evaluation stages: session, pre-request, and post-request. Each stage has specific actions available based on when the policy is evaluated.

Evaluation Stages

Session

When: Connection establishment Actions: allow, block, mfa Use for: Authentication, connection-level access control

Pre-Request

When: Before query reaches resource Actions: allow, block, rewrite Use for: Query validation, SQL rewriting, blocking writes

Post-Request

When: After data returns from resource Actions: allow, filter, mask, decrypt Use for: Data masking, PII redaction, filtering results

Common Action Parameters

All actions support these parameters:
ParameterTypeDescription
actionStringThe enforcement action: allow, block, filter, mask, decrypt, rewrite, mfa
reasonStringExplanation for the action (logged for compliance and auditing)
contextual_dataStringAdditional context that influenced the decision (e.g., “Zendesk ticket #123”)

Block Action

Deny access and terminate the connection or query.

Parameters

ParameterTypeDescription
typeEnumOne of block_with_formal_message or block_with_custom_message
messageStringCustom message to show the user

Example

package formal.v2

import future.keywords.if

# Block all DELETE statements in production
pre_request := {
  "action": "block",
  "type": "block_with_formal_message",
  "message": "DELETE operations are not allowed in production",
  "reason": "Production data protection policy"
} if {
  input.resource.environment == "production"
  input.query.statement_type == "DELETE"
}

Allow Action

Explicitly permit an operation. Use in combination with default deny policies.

Example

package formal.v2

import future.keywords.if
import future.keywords.in

# Default deny
default session := {
  "action": "block",
  "type": "block_with_formal_message"
}

# Allow admins
session := {
  "action": "allow",
  "reason": "User is in admin group"
} if {
  "admin" in input.user.groups
}

Rewrite Action

Modify the query before it reaches the resource.

Parameters

ParameterTypeDescription
rewritten_queryStringThe new query to execute

Example

package formal.v2

import future.keywords.if

# Add LIMIT clause to unbounded queries
pre_request := {
  "action": "rewrite",
  "rewritten_query": sprintf("%s LIMIT 1000", [input.query.query]),
  "reason": "Auto-added row limit for safety"
} if {
  input.query.limit == null
  startswith(input.query.query, "SELECT")
}

Filter Action

Remove rows from the result set based on conditions.

Example

package formal.v2

import future.keywords.if

# Filter rows unless user has open Zendesk ticket
default post_request := {
  "action": "filter",
  "reason": "No open tickets for this data"
}

post_request := {
  "action": "allow",
  "contextual_data": filtered_tickets,
  "reason": "User has open ticket"
} if {
  col := input.row[_]
  col["path"] == "main.public.pii.email"

  filtered_tickets := [obj |
    obj := data.zendesk_tickets[_]
    obj.requester_email == col.value
    obj.status == "open"
  ]

  count(filtered_tickets) > 0
}

Mask Action

Redact or obfuscate sensitive data in responses.

Parameters

ParameterTypeDescription
typeStringMasking type (e.g., redact.partial, hash.with_salt, fake, nullify)
sub_typeStringSpecific masking method (e.g., email_mask_username)
columns[]ColumnList of columns to mask
redactStringReplacement value for redaction
characters_countIntegerNumber of characters to mask
typesafeBooleanMaintain column data type (default: false)
typesafe_fallbackStringfallback_to_null or fallback_to_default

Masking Types

TypePrivacy LevelDescription
nullify4 (Highest)Replace with NULL
hash.with_salt4Hash with random salt
fake3Generate realistic fake data
redact.constant_characters3Replace with constant string
hash.no_salt2Hash without salt (deterministic)
redact.partial1Partially redact (e.g., mask email username)
none0 (Lowest)No masking

Masking Subtypes

  • email_mask_username: ****@example.com - email_mask_domain_name: user@*****.*** - email_mask_while_preserving: a****@e******.com - email_mask_with_fake: fake.email@example.com
  • person_full_name_mask_with_fake - person_first_name_mask_with_fake - person_last_name_mask_with_fake - person_ssn_mask_with_fake
  • postal_address_mask_with_fake - city_mask_with_fake - state_mask_with_fake - zip_mask_with_fake - location_mask_except_state_country
  • payment_credit_card_number_mask_with_fake - payment_credit_card_cvv_mask_with_fake - payment_credit_card_exp_mask_with_fake - payment_ach_routing_with_fake - payment_bitcoin_address_with_fake
  • network_url_mask_with_fake - network_ipv4_mask_with_fake - network_ipv6_mask_with_fake - network_mac_mask_with_fake
  • redact.constant_characters: Replace with custom string - redact.first_n_characters: Mask first N chars - redact.last_n_characters: Mask last N chars - mask_everything_except_last: Show only last N chars

Examples

Mask email usernames:
package formal.v2

import future.keywords.if

post_request := {
  "action": "mask",
  "type": "redact.partial",
  "sub_type": "email_mask_username",
  "columns": columns
} if {
  columns := [col |
    col := input.row[_]
    col["data_label"] == "email_address"
  ]
  count(columns) > 0
}
Replace PII with fake data:
package formal.v2

import future.keywords.if

post_request := {
  "action": "mask",
  "type": "fake",
  "sub_type": "person_full_name_mask_with_fake",
  "columns": columns
} if {
  columns := [col |
    col := input.row[_]
    col["data_label"] == "name"
  ]
  count(columns) > 0
}
Redact with constant value:
package formal.v2

import future.keywords.if

post_request := {
  "action": "mask",
  "type": "redact.constant_characters",
  "sub_type": "redact.constant_characters",
  "redact": "[REDACTED]",
  "columns": columns
} if {
  columns := [col |
    col := input.row[_]
    col["data_label"] == "ssn"
  ]
  count(columns) > 0
}

Decrypt Action

Decrypt previously encrypted columns (requires encryption policy).

Example

package formal.v2

import future.keywords.if
import future.keywords.in

post_request := {
  "action": "decrypt",
  "columns": columns
} if {
  "decrypt_access" in input.user.groups
  columns := [col |
    col := input.row[_]
    col["name"] == "encrypted_ssn"
  ]
}

MFA Action

Require multi-factor authentication before allowing access.

Example

package formal.v2

import future.keywords.if

session := {
  "action": "mfa",
  "reason": "Production access requires MFA"
} if {
  input.resource.environment == "production"
}

Rule Conflicts and Precedence

When multiple policies apply to the same query, Formal resolves conflicts using least privilege:
ScenarioResolution
Block vs AllowBlock wins
Multiple Filter actionsSmallest row limit wins
Multiple Mask actionsHighest privacy level wins (nullify > constant > fake > partial)
Multiple Rewrite actionsArbitrary (avoid conflicts with scoping)

Example of Conflict Resolution

Policy A: Mask email as ***@***.com (privacy level 3)
Policy B: Nullify email (privacy level 4)

Result: Email is nullified (higher privacy)
Use included_connectors or included_resources to avoid unintended policy conflicts.

Scoping Policies with Connectors and Resources

To prevent policy conflicts when using the default keyword, you can limit which Connectors or Resources a policy applies to.

Include/Exclude Connectors

Use included_connectors to apply a policy only to specific connectors:
package formal.v2

import future.keywords.if

# Only apply to these Connectors
included_connectors := ["production-connector", "staging-connector"]

session := {
  "action": "block",
  "type": "block_with_formal_message"
} if {
  input.resource.technology == "postgres"
}
Use excluded_connectors to exclude specific connectors:
package formal.v2

import future.keywords.if

# Exclude these Connectors
excluded_connectors := ["dev-connector"]

session := {
  "action": "block",
  "type": "block_with_formal_message"
} if {
  input.resource.technology == "postgres"
}
A single policy cannot use both included_connectors and excluded_connectors. Choose one or the other.

Include/Exclude Resources

Use included_resources to apply a policy only to specific resources:
package formal.v2

import future.keywords.if

# Only apply to specific resources
included_resources := ["production-postgres"]

session := {
  "action": "block",
  "type": "block_with_formal_message"
} if {
  input.user.type == "machine"
}
Use excluded_resources to exclude specific resources:
package formal.v2

import future.keywords.if

# Exclude specific resources
excluded_resources := ["dev-postgres", "staging-postgres"]

session := {
  "action": "block",
  "type": "block_with_formal_message"
} if {
  "engineer" in input.user.groups
}
A single policy cannot use both included_resources and excluded_resources. Choose one or the other.