Lotus.Visibility (Lotus v1.2.0)

Copy Markdown View Source

Schema, table and column visibility filtering for Lotus.

Implements a three-level visibility system where the outer level takes precedence:

  1. Schema visibility is checked first — if a schema is denied, every table in it is blocked
  2. Table visibility is only checked if the schema is allowed
  3. Column visibility applies to the columns of an allowed table, and can hide a column from discovery or mask its values in results

This ensures security by default while providing fine-grained control.

Understanding Schemas Across Database Systems

Important: "Schema" means different things in different databases:

PostgreSQL

  • True namespaced schemas within a single database
  • Examples: public, reporting, tenant_123
  • System schemas: pg_catalog, information_schema, pg_toast

MySQL

  • Schemas = Databases (synonymous terms)
  • Examples: lotus_production, analytics_db, warehouse
  • System schemas: mysql, information_schema, performance_schema, sys

SQLite

  • No schema support (schema-less database)
  • Schema visibility rules don't apply

Quick Start

config :lotus,
  # Schema-level rules (higher precedence)
  schema_visibility: %{
    postgres: [
      allow: ["public", ~r/^tenant_/],    # Only public + tenant schemas
      deny: ["legacy"]                    # Block legacy schema
    ],
    mysql: [
      allow: ["app_db", "analytics_db"],  # Only these databases
      deny: ["staging_db"]                # Block staging database
    ]
  },

  # Table-level rules (lower precedence)
  table_visibility: %{
    default: [
      deny: ["user_passwords", "api_keys", ~r/^audit_/]
    ],
    postgres: [
      allow: [
        {"public", ~r/^dim_/},           # Dimension tables only
        {"analytics", ~r/.*/}            # All analytics tables
      ]
    ]
  }

Rule Evaluation

1. Schema Gating (First Check)

if not allowed_schema?(repo_name, schema) do
  false  # Schema denied → all tables blocked
else
  # Schema allowed → check table rules
end

2. Schema-Scoped Allow Posture

Allow rules are scoped to specific schemas, not global:

# Rules: allow: [{"restricted", "allowed_table"}]

{"restricted", "any_table"} → denied (has allow posture, must match)
{"public", "any_table"} → allowed (no allow posture for public)

3. Deny Always Wins

Any deny rule (builtin or user-defined) blocks access immediately.

Rule Formats

Schema Rules

  • "exact_name" - Matches exact schema name
  • ~r/pattern/ - Regex pattern for dynamic matching
  • :all - Special allow value (permits all schemas)

Table Rules

  • {"schema", "table"} - Exact schema.table match
  • {"schema", ~r/pattern/} - Tables matching regex in specific schema
  • {~r/schema_pattern/, "table"} - Table in schemas matching pattern
  • "table" - Table name in any schema (global rule)

Built-in Security

System schemas are automatically denied:

  • PostgreSQL: pg_catalog, information_schema, pg_toast, pg_temp_*
  • MySQL: mysql, information_schema, performance_schema, sys
  • All databases: schema_migrations, lotus_queries

Examples

Multi-tenant Application

config :lotus,
  schema_visibility: %{
    postgres: [
      allow: ["public", ~r/^tenant_\d+$/],  # tenant_123, etc.
      deny: ["admin"]
    ]
  },
  table_visibility: %{
    postgres: [
      allow: [
        {"public", ~r/^shared_/},        # Shared lookup tables
        {~r/^tenant_/, "users"},         # Users in each tenant
        {~r/^tenant_/, "orders"}         # Orders in each tenant
      ],
      deny: [
        {~r/^tenant_/, "audit_logs"}     # Hide audit logs
      ]
    ]
  }

Data Warehouse

config :lotus,
  schema_visibility: %{
    postgres: [
      allow: ["public", "warehouse", "analytics"]
    ]
  },
  table_visibility: %{
    postgres: [
      allow: [
        {"public", ~r/^dim_/},         # Dimension tables
        {"public", ~r/^fact_/},        # Fact tables
        {"warehouse", ~r/.*/},         # All warehouse
        {"analytics", ~r/^report_/}    # Only reports
      ],
      deny: [
        {"public", ~r/^raw_/}          # Hide raw data
      ]
    ]
  }

MySQL Multi-Database

config :lotus,
  schema_visibility: %{
    mysql: [
      # Remember: schemas = databases in MySQL
      allow: ["app_production", "analytics_warehouse"],
      deny: ["staging_db", "backup_db"]
    ]
  }

API

Direct visibility checking:

# Check schema visibility
Lotus.Visibility.allowed_schema?("postgres", "public")  # true/false

# Check table visibility
Lotus.Visibility.allowed_relation?("postgres", {"public", "users"})  # true/false

# Filter lists
Lotus.Visibility.filter_schemas(["public", "pg_catalog"], "postgres")  # ["public"]

# Validate requested schemas
Lotus.Visibility.validate_schemas(["public", "restricted"], "postgres")
# :ok | {:error, :schema_not_visible, denied: [...]}

Schema-aware Lotus functions automatically apply visibility:

{:ok, schemas} = Lotus.list_schemas("postgres")        # Filtered list
{:ok, tables} = Lotus.list_tables("postgres")          # Filtered list
{:error, msg} = Lotus.list_tables("postgres", schemas: ["denied"])  # Error

For more detailed examples and configuration patterns, see the Visibility Guide.

Summary

Types

What every check accepts in place of a source name: a compiled Lotus.Visibility.Matcher, or a raw rule set with optional :schema, :table and :column keys in the formats the resolver callbacks return. A raw rule set is compiled as given, with no builtin denies.

Functions

Checks if a relation (schema, table) is allowed for the given source.

Checks if a schema is visible for the given source.

Resolves the column policy for a given result column name in the context of accessed relations and source.

Filters a list of relations to only those that are visible.

Filters a list of schemas to only those that are visible.

Returns the compiled matcher for a source, from the configured resolver.

Validates that all requested schemas are visible.

Types

rules()

@type rules() :: %{
  optional(:schema) => keyword(),
  optional(:table) => keyword(),
  optional(:column) => list()
}

source()

@type source() :: String.t() | Lotus.Visibility.Matcher.t() | rules()

What every check accepts in place of a source name: a compiled Lotus.Visibility.Matcher, or a raw rule set with optional :schema, :table and :column keys in the formats the resolver callbacks return. A raw rule set is compiled as given, with no builtin denies.

Functions

allowed_relation?(source, relation, scope \\ nil)

@spec allowed_relation?(source(), {String.t() | nil, String.t()}, term()) :: boolean()

Checks if a relation (schema, table) is allowed for the given source.

Schema visibility is checked first, then table visibility. source is a source name, a compiled matcher or a raw rule set (see source/0).

allowed_schema?(source, schema, scope \\ nil)

@spec allowed_schema?(source(), String.t() | nil, term()) :: boolean()

Checks if a schema is visible for the given source.

source is a source name, a compiled matcher or a raw rule set (see source/0).

Returns:

  • true if the schema is allowed
  • false if the schema is denied

column_policy_for(source, relations, result_column_name, scope \\ nil)

@spec column_policy_for(
  source(),
  [{String.t() | nil, String.t()}] | nil,
  String.t(),
  term()
) ::
  nil | %{action: atom(), mask: any(), show_in_schema?: boolean()}

Resolves the column policy for a given result column name in the context of accessed relations and source.

source is a source name, a compiled matcher or a raw rule set (see source/0). Rules support patterns on schema, table, and column names. Returns a normalized policy map or nil.

compile(rules, opts \\ [])

Compiles a rule set into a Lotus.Visibility.Matcher.

Exact names become sets and regex rules a short ordered list, so a check against the matcher does not walk the raw rules. Pure: the same rules give the same matcher. A matcher passed in is returned as it is, apart from the merge below.

Options

  • :adapter — an %Lotus.Source.Adapter{} whose built-in schema and table denies are merged into the matcher, or nil for the conservative fallback denies used when no source can be resolved.
  • :builtin_schema_denies, :builtin_table_denies — the deny lists to merge when the caller has them instead of the adapter.

A resolver that serves rules changed at runtime compiles once when a rule set is written and returns the matcher from Lotus.Visibility.Resolver.matcher_for/2.

filter_relations(relations, source, scope \\ nil)

@spec filter_relations([{String.t() | nil, String.t()}], source(), term()) :: [
  {String.t() | nil, String.t()}
]

Filters a list of relations to only those that are visible.

filter_schemas(schemas, source, scope \\ nil)

@spec filter_schemas([String.t()], source(), term()) :: [String.t()]

Filters a list of schemas to only those that are visible.

matcher_for(source_name, scope \\ nil)

@spec matcher_for(String.t(), term()) :: Lotus.Visibility.Matcher.t()

Returns the compiled matcher for a source, from the configured resolver.

A resolver that exports Lotus.Visibility.Resolver.matcher_for/2 answers directly. Otherwise its three rule callbacks are compiled here, with the built-in denies of the source's adapter merged in.

Call this once per result or per discovery call and pass the matcher to the checks below, instead of passing the source name to every check.

validate_schemas(schemas, source, scope \\ nil)

@spec validate_schemas([String.t()], source(), term()) ::
  :ok | {:error, :schema_not_visible, [{:denied, [String.t()]}]}

Validates that all requested schemas are visible.

Returns:

  • :ok if all schemas are visible
  • {:error, :schema_not_visible, denied: [schemas]} if any are denied