Visibility Rules
View SourceLotus provides a comprehensive visibility system that controls which schemas and tables are accessible through the API. This system operates on two levels with clear precedence rules to ensure security while maintaining flexibility.
Overview
The visibility system uses a two-level hierarchy:
- Schema visibility (higher precedence)
- Table visibility (lower precedence)
Key principle: If a schema is denied, all tables within it are automatically blocked, regardless of table-level rules.
Database-Specific Schema Behavior
Understanding how "schemas" work across different database systems is crucial for configuring visibility rules correctly:
PostgreSQL
- True namespaced schemas: Multiple schemas exist within a single database
- Examples:
public,reporting,analytics,tenant_123 - System schemas:
pg_catalog,information_schema,pg_toast,pg_temp_* - Qualified names:
reporting.customers,public.users
MySQL
- Schemas = Databases: In MySQL, "schema" and "database" are synonymous
- Examples:
lotus_production,analytics_db,warehouse - System schemas:
mysql,information_schema,performance_schema,sys - Behavior: When you connect to MySQL, you can access tables across different databases/schemas
- Qualified names:
analytics_db.customers,warehouse.sales
SQLite
- No schema support: SQLite is schema-less
- Schema visibility: Not applicable (always empty list)
- Tables: Exist at the database level without namespace prefixes
Configuration
Configure visibility rules in your application config:
config :lotus,
# Schema-level visibility (higher precedence)
schema_visibility: %{
default: [
deny: ["restricted_schema", ~r/^temp_/],
allow: :all # or specific schemas
],
postgres: [
# Only allow public and tenant schemas
allow: ["public", ~r/^tenant_\d+$/],
deny: ["legacy_schema"]
],
mysql: [
# In MySQL, these are database names
allow: ["lotus_production", "analytics_warehouse"],
deny: ["staging_db", "backup_db"]
]
},
# Table-level visibility (lower precedence)
table_visibility: %{
default: [
deny: ["user_passwords", "api_keys", ~r/^audit_/],
allow: [] # empty = allow all (except denied)
],
postgres: [
# Data warehouse example
allow: [
{"public", ~r/^dim_/}, # Dimension tables
{"public", ~r/^fact_/}, # Fact tables
{"analytics", ~r/.*/} # All analytics tables
],
deny: [
{"public", ~r/^staging_/}, # Block staging tables
{"public", ~r/_temp$/} # Block temp tables
]
]
}Rule Syntax
Schema Rules
Schema rules use simpler patterns since they only match schema names:
"exact_name"- Matches exact schema name~r/pattern/- Regex pattern for dynamic matching:all- Special value for allow rules (allows all schemas)
Examples:
allow: ["public", "reporting", ~r/^tenant_\w+$/]
deny: ["restricted", ~r/^temp_/]Table Rules
Table rules support multiple formats for maximum flexibility:
{"schema", "table"}- Exact schema.table match{"schema", ~r/pattern/}- Regex table pattern in specific schema{~r/schema_pattern/, "table"}- Table in schemas matching pattern"table"- Table name in any schema (global rule)
Examples:
allow: [
{"public", "users"}, # Specific table
{"reporting", ~r/^daily_/}, # Tables starting with "daily_" in reporting
{~r/^tenant_/, "customers"}, # customers table in any tenant schema
"products" # products table in any schema
]Precedence and Evaluation
1. Schema Gating (First)
if not allowed_schema?(schema) do
deny # Schema denied → everything in it blocked
else
# Schema allowed → proceed to table rules
end2. Deny Always Wins (Second)
if deny_rule_matches?(schema, table) do
deny # Any deny rule blocks access
else
# Check allow posture
end3. Schema-Scoped Allow Posture (Third)
For each schema, compute its "allow posture":
- Has allow posture: Allow rules exist that could apply to this schema
- No allow posture: No allow rules target this schema
# Rules: allow: [{"restricted", "allowed_table"}]
# For "restricted" schema:
allow_posture? = true # Has rule targeting "restricted"
# → Default-deny: only "allowed_table" allowed
# For "public" schema:
allow_posture? = false # No rules targeting "public"
# → Default-allow: all tables allowed (unless denied)Practical Examples
Multi-Tenant SaaS Application
config :lotus,
schema_visibility: %{
postgres: [
# Only allow public and tenant schemas
allow: ["public", ~r/^tenant_\d+$/],
deny: ["admin_schema", "system_logs"]
]
},
table_visibility: %{
postgres: [
allow: [
{"public", ~r/^shared_/}, # Shared lookup tables
{~r/^tenant_/, "users"}, # User table in each tenant
{~r/^tenant_/, "orders"}, # Order table in each tenant
{~r/^tenant_/, "products"} # Product table in each tenant
],
deny: [
{~r/^tenant_/, "internal_logs"}, # Hide logs from all tenants
"api_keys" # Hide API keys globally
]
]
}Result:
- ✅
tenant_123.users→ Allowed - ✅
public.shared_categories→ Allowed - ❌
tenant_123.internal_logs→ Denied (table rule) - ❌
admin_schema.anything→ Denied (schema rule)
Data Warehouse
config :lotus,
schema_visibility: %{
postgres: [
allow: ["public", "warehouse", "analytics"],
deny: ["staging", "etl_temp"]
]
},
table_visibility: %{
postgres: [
allow: [
{"public", ~r/^dim_/}, # Dimension tables
{"public", ~r/^fact_/}, # Fact tables
{"warehouse", ~r/.*/}, # All warehouse tables
{"analytics", ~r/^report_/} # Only report tables in analytics
],
deny: [
{"public", ~r/^raw_/}, # Hide raw data tables
{"warehouse", ~r/_backup$/} # Hide backup tables
]
]
}MySQL Multi-Database Setup
config :lotus,
schema_visibility: %{
mysql: [
# Remember: schemas = databases in MySQL
allow: ["lotus_production", "analytics_warehouse", "reporting_db"],
deny: ["staging_db", "backup_db", "temp_imports"]
]
},
table_visibility: %{
mysql: [
allow: [
{"lotus_production", ~r/^public_/}, # Only public tables from main DB
{"analytics_warehouse", ~r/.*/}, # All analytics tables
{"reporting_db", ~r/^report_/} # Only reports from reporting DB
],
deny: [
"user_passwords", # Hide globally
{"lotus_production", ~r/^internal_/} # Hide internal tables
]
]
}Testing Visibility Rules
You can test your visibility configuration using the programmatic API:
# Test schema visibility
{:ok, schemas} = Lotus.list_schemas("postgres")
# Returns only visible schemas
# Test table visibility
{:ok, tables} = Lotus.list_tables("postgres", schema: "public")
# Returns only visible tables in public schema
# Direct visibility check
Lotus.Visibility.allowed_schema?("postgres", "restricted")
# Returns true/false
Lotus.Visibility.allowed_relation?("postgres", {"public", "users"})
# Returns true/falseError Handling
When schema visibility blocks access, you'll receive clear error messages:
# Trying to list tables in denied schema
{:error, "Schema(s) not visible: pg_catalog, restricted"} =
Lotus.list_tables("postgres", schemas: ["public", "pg_catalog", "restricted"])
# Validation helper
{:error, :schema_not_visible, denied: ["pg_catalog"]} =
Lotus.Visibility.validate_schemas(["public", "pg_catalog"], "postgres")Built-in Security
Lotus automatically denies system schemas to prevent accidental exposure:
PostgreSQL Built-ins
pg_catalog- System cataloginformation_schema- SQL standard metadatapg_toast- TOAST storage~r/^pg_temp/- Temporary schemas~r/^pg_toast/- Additional TOAST schemas
MySQL Built-ins
mysql- MySQL system databaseinformation_schema- SQL standard metadataperformance_schema- Performance monitoringsys- Diagnostic information
All Databases
- Migration tables (e.g.,
schema_migrations) - Lotus internal tables (e.g.,
lotus_queries)
These built-in denies always apply, even if your custom rules would allow them. This ensures security by default.
Migration from Table-Only Rules
If you're upgrading from table-only visibility rules:
- Review existing rules: Identify any schema-specific patterns
- Extract schema rules: Move schema-level restrictions to
schema_visibility - Test thoroughly: Schema rules take precedence and can block more than expected
- Use validation: Test your configuration with
Lotus.Visibility.validate_schemas/2
Best Practices
- Start restrictive: Use allow lists for sensitive environments
- Layer security: Use schema rules for broad restrictions, table rules for fine-tuning
- Test configurations: Use the programmatic API to verify your rules work as expected
- Document your rules: Complex visibility configurations should be documented for your team
- Consider performance: Schema-level filtering is more efficient than table-level
- MySQL considerations: Remember that schemas = databases in MySQL
- Regex careful: Test regex patterns thoroughly to avoid unintended matches
Common Patterns
Development vs Production
# Development - more permissive
config :lotus,
schema_visibility: %{
default: [allow: :all, deny: ["dangerous_schema"]]
}
# Production - restrictive allowlist
config :lotus,
schema_visibility: %{
default: [
allow: ["public", "reporting"],
deny: [] # not needed with restrictive allow
]
}Dynamic tenant schemas
config :lotus,
schema_visibility: %{
postgres: [
allow: ["public", ~r/^tenant_[a-f0-9]{8}$/], # UUID-based tenants
deny: []
]
}Supabase Configuration
When using Supabase as your database, it includes many additional system schemas that you typically don't want your users to query directly through Lotus. It's recommended to configure Lotus with schema deny rules to hide these internal schemas:
config :lotus,
schema_visibility: %{
default: [
deny: [
"auth", # Supabase authentication
"extensions", # PostgreSQL extensions
"graphql", # GraphQL schema
"graphql_public", # Public GraphQL schema
"pgbouncer", # Connection pooler
"realtime", # Realtime subscriptions
"storage", # File storage
"vault", # Secrets management
"pg_catalog", # PostgreSQL system catalog
"information_schema", # SQL standard metadata
"pg_toast" # TOAST storage
]
]
}This configuration ensures that:
- Users can only query your application's schemas (like
public) - Supabase's internal schemas remain hidden from the Lotus interface
- System schemas are properly protected from accidental exposure
Note: The last three schemas (pg_catalog, information_schema, pg_toast) are already blocked by Lotus's built-in security, but including them explicitly in your configuration makes the intent clear.
Column-Level Visibility
Column visibility provides fine-grained control over individual columns within tables. You can hide sensitive data, mask personally identifiable information (PII), or prevent certain columns from being queried entirely.
Configuration
Add column visibility rules to your configuration:
config :lotus,
column_visibility: %{
default: [
# Hide sensitive columns globally
{"password", :error},
{"ssn", [action: :mask, mask: :sha256]},
{"api_key", :omit}
],
postgres: [
# Schema + table + column rules (most specific)
{"public", "users", "email", [action: :mask, mask: {:partial, keep_last: 4}]},
{"public", "users", "credit_card", :error},
# Table + column rules (any schema)
{"orders", "total", [action: :mask, mask: {:fixed, "HIDDEN"}]},
# Column rules (any schema/table)
{"created_by", :omit},
{"debug_info", [action: :omit, show_in_schema?: false]}
]
}Actions
Column policies support four actions:
:allow- Show column values normally (default behavior):omit- Remove column entirely from query results:mask- Transform/redact column values using a masking strategy:error- Fail the query if this column is selected
Masking Strategies
When using :mask action, choose from these strategies:
:null - Replace with NULL
{"users", "middle_name", [action: :mask, mask: :null]}:sha256 - Replace with SHA256 hash
{"users", "ssn", [action: :mask, mask: :sha256]}
# "123-45-6789" becomes "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"{:fixed, value} - Replace with fixed value
{"users", "salary", [action: :mask, mask: {:fixed, "CONFIDENTIAL"}]}{:partial, options} - Partial masking
# Keep last 4 characters, mask the rest
{"users", "phone", [action: :mask, mask: {:partial, keep_last: 4}]}
# "555-123-4567" becomes "*******4567"
# Keep first 2 and last 4, custom replacement
{"users", "email", [action: :mask, mask: {:partial, keep_first: 2, keep_last: 4, replacement: "#"}]}
# "john@example.com" becomes "jo#######.com"Schema Introspection Control
Control whether columns appear in schema introspection:
column_visibility: %{
default: [
# Column is masked but visible in schema
{"password_hash", [action: :mask, mask: :sha256, show_in_schema?: true]},
# Column is omitted and hidden from schema
{"internal_notes", [action: :omit, show_in_schema?: false]}
]
}Pattern Matching
Use regex patterns for flexible column matching:
column_visibility: %{
postgres: [
# Hide all columns ending in _secret
{~r/_secret$/, :error},
# Mask all PII columns across specific tables
{"users", ~r/(ssn|phone|email)/, [action: :mask, mask: :sha256]},
# Hash all audit columns in analytics schema
{"analytics", ~r/.*/, ~r/^audit_/, [action: :mask, mask: :sha256]}
]
}Simple Syntax
For common cases, use atom shortcuts:
column_visibility: %{
default: [
{"password", :error}, # Same as [action: :error]
{"temp_data", :omit}, # Same as [action: :omit]
{"user_agent", :mask} # Same as [action: :mask, mask: :null]
]
}Precedence Rules
Column rules are evaluated in this order (most to least specific):
- Schema + Table + Column -
{"public", "users", "email", policy} - Table + Column -
{"users", "email", policy} - Column Only -
{"email", policy}
The most specific matching rule wins.
Examples
PII Protection
column_visibility: %{
default: [
{"ssn", [action: :mask, mask: :sha256]},
{"credit_card", :error},
{"phone", [action: :mask, mask: {:partial, keep_last: 4}]},
{"email", [action: :mask, mask: {:partial, keep_first: 2, keep_last: 8}]}
]
}Development vs Production
# Different rules per environment
column_visibility: %{
default: [
{"password", if(Mix.env() == :prod, do: :error, else: :allow)},
{"debug_info", if(Mix.env() == :prod, do: :omit, else: :allow)}
]
}Multi-tenant Data
column_visibility: %{
postgres: [
# Hide tenant isolation columns
{~r/^tenant_\d+/, ~r/.*/, "tenant_id", :omit},
# Mask cross-tenant data
{"shared_data", "user_reference", [action: :mask, mask: :sha256]}
]
}