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
limitIntegerThe limit to apply to the query
commentStringThe comment to add to the query

Example

package formal.v2

import future.keywords.if

# Add LIMIT clause to unbounded queries
request := {
  "action": "rewrite",
  "limit": 1000,
  "comment": "Auto-added row limit for safety",
} if {
  startswith(lower(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.