Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
7a002a0
feat(experimental): add official support for grants
newtonapple Sep 2, 2025
bfc365c
fix: rework dbt grants to sqlmesh grants converstion
newtonapple Sep 2, 2025
cb505d0
fix: snapshot test due to metadata changes
newtonapple Sep 2, 2025
ca207bc
remove _apply_grants_config _revoke_grants_config API from engine ada…
newtonapple Sep 3, 2025
a9996cc
fix: expand grants_config parsing to support more complex expressions
newtonapple Sep 3, 2025
5efad1a
refactor: consolidate grants tests
newtonapple Sep 4, 2025
603cef1
refactor: move private grants method to the end of EngineAdapter
newtonapple Sep 4, 2025
4e42ba1
Push apply grants down to evaluation strategies
newtonapple Sep 9, 2025
12fd5b0
fix: ignore grants for model kinds that don't support it
newtonapple Sep 9, 2025
f7e3b55
fix: add column types for non-SEED test models
newtonapple Sep 10, 2025
d2b1911
fix: apply grants to the physical layer table on promote
newtonapple Sep 11, 2025
e3d5356
refactor: add skip_grants flag to SnapshotEvaluator.create
newtonapple Sep 11, 2025
9c3c2b2
fix: allow sync_grants_config to be duplicated on when planned w/ cre…
newtonapple Sep 12, 2025
bb5e171
remove virtual_environment_mode field_validator
newtonapple Sep 16, 2025
4be3d74
fix: ensure dev_only VDE always applies grants in production
newtonapple Sep 18, 2025
5354644
fix _replace_query_for_model now requires **kwargs & specifically "is…
newtonapple Sep 18, 2025
4edece1
Change default grants_target_layer to VIRTUAL
newtonapple Sep 18, 2025
19a00b9
add grants and grants_target_layer metadata migration
newtonapple Sep 18, 2025
081c170
remove outdated comment
newtonapple Sep 18, 2025
409940f
remove dead code
newtonapple Sep 19, 2025
93c838d
fix: SCD Type 2 models should apply grants on insert
newtonapple Sep 19, 2025
7f6d173
refactor: grants validations and _apply_grants logic
newtonapple Sep 21, 2025
576b3c1
fix migration version conflict & test changes after rebasing main
newtonapple Sep 23, 2025
84988c3
make is_snapshot_deployable optional
newtonapple Sep 23, 2025
42030e4
Add metadata-only grants for different model types
newtonapple Sep 23, 2025
5c4f9e2
feat: add snowflake grant support (#5433)
eakmanrq Sep 24, 2025
ec7a704
Remove physical grants application in promotion stage.
newtonapple Sep 24, 2025
a7d52da
feat: Databricks grants (#5436)
eakmanrq Sep 25, 2025
0587a9d
feat: redshift grant support (#5440)
eakmanrq Sep 25, 2025
598749a
fix: force seed model to rebuild on grant changes
newtonapple Sep 25, 2025
11db09e
chore: grant mixin and normalize (#5447)
eakmanrq Sep 29, 2025
98cad60
feat: add grants for BigQuery (#5444)
newtonapple Sep 29, 2025
090fe11
uncomment integration tests & fix dupicate migration
newtonapple Sep 29, 2025
4c14578
inline current_schema() expression in base_postgres._get_current_sche…
newtonapple Sep 29, 2025
d1639da
fix: map materialized views to views when granting permissions
newtonapple Sep 30, 2025
4301c4d
refactor: rename grant_config to grants_config for consistency
newtonapple Sep 30, 2025
e8b93d3
fix: bigquery actually requires "MATERIALIZED VIEW" in DCL for
newtonapple Sep 30, 2025
0a2495c
turn on integration tests.
newtonapple Sep 30, 2025
3e06aca
fix redshift view grant
eakmanrq Sep 30, 2025
a212ec8
refactor: remove _grant_object_kind from redshift as object_type /
newtonapple Sep 30, 2025
3d23892
Revert "turn on integration tests."
newtonapple Sep 30, 2025
28e6156
fix: mypy after rebasing main
newtonapple Oct 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/continue_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ jobs:
command: ./.circleci/test_migration.sh sushi "--gateway duckdb_persistent"
- run:
name: Run the migration test - sushi_dbt
command: ./.circleci/test_migration.sh sushi_dbt "--config migration_test_config"
command: ./.circleci/test_migration.sh sushi_dbt "--config migration_test_config"

ui_style:
docker:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ dmypy.json
*~
*#

# Vim
*.swp
*.swo
.null-ls*


*.duckdb
*.duckdb.wal

Expand All @@ -158,3 +164,4 @@ spark-warehouse/

# claude
.claude/

1 change: 1 addition & 0 deletions sqlmesh/core/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
SessionProperties = t.Dict[str, t.Union[exp.Expression, str, int, float, bool]]
CustomMaterializationProperties = t.Dict[str, t.Union[exp.Expression, str, int, float, bool]]


if sys.version_info >= (3, 11):
from typing import Self as Self
else:
Expand Down
2 changes: 2 additions & 0 deletions sqlmesh/core/engine_adapter/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@
]

QueryOrDF = t.Union[Query, DF]
GrantsConfig = t.Dict[str, t.List[str]]
DCL = t.TypeVar("DCL", exp.Grant, exp.Revoke)
147 changes: 147 additions & 0 deletions sqlmesh/core/engine_adapter/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
from sqlmesh.core.engine_adapter._typing import (
DF,
BigframeSession,
GrantsConfig,
PySparkDataFrame,
PySparkSession,
Query,
Expand Down Expand Up @@ -114,6 +115,7 @@ class EngineAdapter:
SUPPORTS_TUPLE_IN = True
HAS_VIEW_BINDING = False
SUPPORTS_REPLACE_TABLE = True
SUPPORTS_GRANTS = False
DEFAULT_CATALOG_TYPE = DIALECT
QUOTE_IDENTIFIERS_IN_VIEWS = True
MAX_IDENTIFIER_LENGTH: t.Optional[int] = None
Expand Down Expand Up @@ -2478,6 +2480,33 @@ def wap_publish(self, table_name: TableName, wap_id: str) -> None:
"""
raise NotImplementedError(f"Engine does not support WAP: {type(self)}")

def sync_grants_config(
self,
table: exp.Table,
grants_config: GrantsConfig,
table_type: DataObjectType = DataObjectType.TABLE,
) -> None:
"""Applies the grants_config to a table authoritatively.
It first compares the specified grants against the current grants, and then
applies the diffs to the table by revoking and granting privileges as needed.

Args:
table: The table/view to apply grants to.
grants_config: Dictionary mapping privileges to lists of grantees.
table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW).
"""
if not self.SUPPORTS_GRANTS:
raise NotImplementedError(f"Engine does not support grants: {type(self)}")

current_grants = self._get_current_grants_config(table)
new_grants, revoked_grants = self._diff_grants_configs(grants_config, current_grants)
revoke_exprs = self._revoke_grants_config_expr(table, revoked_grants, table_type)
grant_exprs = self._apply_grants_config_expr(table, new_grants, table_type)
dcl_exprs = revoke_exprs + grant_exprs

if dcl_exprs:
self.execute(dcl_exprs)

@contextlib.contextmanager
def transaction(
self,
Expand Down Expand Up @@ -3029,6 +3058,124 @@ def _check_identifier_length(self, expression: exp.Expression) -> None:
def get_table_last_modified_ts(self, table_names: t.List[TableName]) -> t.List[int]:
raise NotImplementedError()

@classmethod
def _diff_grants_configs(
cls, new_config: GrantsConfig, old_config: GrantsConfig
) -> t.Tuple[GrantsConfig, GrantsConfig]:
"""Compute additions and removals between two grants configurations.

This method compares new (desired) and old (current) GrantsConfigs case-insensitively
for both privilege keys and grantees, while preserving original casing
in the output GrantsConfigs.

Args:
new_config: Desired grants configuration (specified by the user).
old_config: Current grants configuration (returned by the database).

Returns:
A tuple of (additions, removals) GrantsConfig where:
- additions contains privileges/grantees present in new_config but not in old_config
- additions uses keys and grantee strings from new_config (user-specified casing)
- removals contains privileges/grantees present in old_config but not in new_config
- removals uses keys and grantee strings from old_config (database-returned casing)

Notes:
- Comparison is case-insensitive using casefold(); original casing is preserved in results.
- Overlapping grantees (case-insensitive) are excluded from the results.
"""

def _diffs(config1: GrantsConfig, config2: GrantsConfig) -> GrantsConfig:
diffs: GrantsConfig = {}
cf_config2 = {k.casefold(): {g.casefold() for g in v} for k, v in config2.items()}
for key, grantees in config1.items():
cf_key = key.casefold()

# Missing key (add all grantees)
if cf_key not in cf_config2:
diffs[key] = grantees.copy()
continue

# Include only grantees not in config2
cf_grantees2 = cf_config2[cf_key]
diff_grantees = []
for grantee in grantees:
if grantee.casefold() not in cf_grantees2:
diff_grantees.append(grantee)
if diff_grantees:
diffs[key] = diff_grantees
return diffs

return _diffs(new_config, old_config), _diffs(old_config, new_config)

def _get_current_grants_config(self, table: exp.Table) -> GrantsConfig:
"""Returns current grants for a table as a dictionary.

This method queries the database and returns the current grants/permissions
for the given table, parsed into a dictionary format. The it handles
case-insensitive comparison between these current grants and the desired
grants from model configuration.

Args:
table: The table/view to query grants for.

Returns:
Dictionary mapping permissions to lists of grantees. Permission names
should be returned as the database provides them (typically uppercase
for standard SQL permissions, but engine-specific roles may vary).

Raises:
NotImplementedError: If the engine does not support grants.
"""
if not self.SUPPORTS_GRANTS:
raise NotImplementedError(f"Engine does not support grants: {type(self)}")
raise NotImplementedError("Subclass must implement get_current_grants")

def _apply_grants_config_expr(
self,
table: exp.Table,
grants_config: GrantsConfig,
table_type: DataObjectType = DataObjectType.TABLE,
) -> t.List[exp.Expression]:
"""Returns SQLGlot Grant expressions to apply grants to a table.

Args:
table: The table/view to grant permissions on.
grants_config: Dictionary mapping permissions to lists of grantees.
table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW).

Returns:
List of SQLGlot expressions for grant operations.

Raises:
NotImplementedError: If the engine does not support grants.
"""
if not self.SUPPORTS_GRANTS:
raise NotImplementedError(f"Engine does not support grants: {type(self)}")
raise NotImplementedError("Subclass must implement _apply_grants_config_expr")

def _revoke_grants_config_expr(
self,
table: exp.Table,
grants_config: GrantsConfig,
table_type: DataObjectType = DataObjectType.TABLE,
) -> t.List[exp.Expression]:
"""Returns SQLGlot expressions to revoke grants from a table.

Args:
table: The table/view to revoke permissions from.
grants_config: Dictionary mapping permissions to lists of grantees.
table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW).

Returns:
List of SQLGlot expressions for revoke operations.

Raises:
NotImplementedError: If the engine does not support grants.
"""
if not self.SUPPORTS_GRANTS:
raise NotImplementedError(f"Engine does not support grants: {type(self)}")
raise NotImplementedError("Subclass must implement _revoke_grants_config_expr")


class EngineAdapterWithIndexSupport(EngineAdapter):
SUPPORTS_INDEXES = True
Expand Down
8 changes: 8 additions & 0 deletions sqlmesh/core/engine_adapter/base_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def columns(
raise SQLMeshError(
f"Could not get columns for table '{table.sql(dialect=self.dialect)}'. Table not found."
)

return {
column_name: exp.DataType.build(data_type, dialect=self.dialect, udt=True)
for column_name, data_type in resp
Expand Down Expand Up @@ -196,3 +197,10 @@ def _get_data_objects(
)
for row in df.itertuples()
]

def _get_current_schema(self) -> str:
"""Returns the current default schema for the connection."""
result = self.fetchone(exp.select(exp.func("current_schema")))
if result and result[0]:
return result[0]
return "public"
112 changes: 110 additions & 2 deletions sqlmesh/core/engine_adapter/bigquery.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sqlmesh.core.engine_adapter.base import _get_data_object_cache_key
from sqlmesh.core.engine_adapter.mixins import (
ClusteredByMixin,
GrantsFromInfoSchemaMixin,
RowDiffMixin,
TableAlterClusterByOperation,
)
Expand Down Expand Up @@ -40,7 +41,7 @@
from google.cloud.bigquery.table import Table as BigQueryTable

from sqlmesh.core._typing import SchemaName, SessionProperties, TableName
from sqlmesh.core.engine_adapter._typing import BigframeSession, DF, Query
from sqlmesh.core.engine_adapter._typing import BigframeSession, DCL, DF, GrantsConfig, Query
from sqlmesh.core.engine_adapter.base import QueryOrDF


Expand All @@ -55,7 +56,7 @@


@set_catalog()
class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin):
class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin, GrantsFromInfoSchemaMixin):
"""
BigQuery Engine Adapter using the `google-cloud-bigquery` library's DB API.
"""
Expand All @@ -65,6 +66,11 @@ class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin):
SUPPORTS_TRANSACTIONS = False
SUPPORTS_MATERIALIZED_VIEWS = True
SUPPORTS_CLONING = True
SUPPORTS_GRANTS = True
CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expression = exp.func("session_user")
SUPPORTS_MULTIPLE_GRANT_PRINCIPALS = True
USE_CATALOG_IN_GRANTS = True
GRANT_INFORMATION_SCHEMA_TABLE_NAME = "OBJECT_PRIVILEGES"
MAX_TABLE_COMMENT_LENGTH = 1024
MAX_COLUMN_COMMENT_LENGTH = 1024
SUPPORTS_QUERY_EXECUTION_TRACKING = True
Expand Down Expand Up @@ -1326,6 +1332,108 @@ def _session_id(self) -> t.Any:
def _session_id(self, value: t.Any) -> None:
self._connection_pool.set_attribute("session_id", value)

def _get_current_schema(self) -> str:
raise NotImplementedError("BigQuery does not support current schema")

def _get_bq_dataset_location(self, project: str, dataset: str) -> str:
return self._db_call(self.client.get_dataset, dataset_ref=f"{project}.{dataset}").location

def _get_grant_expression(self, table: exp.Table) -> exp.Expression:
if not table.db:
raise ValueError(
f"Table {table.sql(dialect=self.dialect)} does not have a schema (dataset)"
)
project = table.catalog or self.get_current_catalog()
if not project:
raise ValueError(
f"Table {table.sql(dialect=self.dialect)} does not have a catalog (project)"
)

dataset = table.db
table_name = table.name
location = self._get_bq_dataset_location(project, dataset)

# https://cloud.google.com/bigquery/docs/information-schema-object-privileges
# OBJECT_PRIVILEGES is a project-level INFORMATION_SCHEMA view with regional qualifier
object_privileges_table = exp.to_table(
f"`{project}`.`region-{location}`.INFORMATION_SCHEMA.{self.GRANT_INFORMATION_SCHEMA_TABLE_NAME}",
dialect=self.dialect,
)
return (
exp.select("privilege_type", "grantee")
.from_(object_privileges_table)
.where(
exp.and_(
exp.column("object_schema").eq(exp.Literal.string(dataset)),
exp.column("object_name").eq(exp.Literal.string(table_name)),
# Filter out current_user
# BigQuery grantees format: "user:email" or "group:name"
exp.func("split", exp.column("grantee"), exp.Literal.string(":"))[
exp.func("OFFSET", exp.Literal.number("1"))
].neq(self.CURRENT_USER_OR_ROLE_EXPRESSION),
)
)
)

@staticmethod
def _grant_object_kind(table_type: DataObjectType) -> str:
if table_type == DataObjectType.VIEW:
return "VIEW"
if table_type == DataObjectType.MATERIALIZED_VIEW:
# We actually need to use "MATERIALIZED VIEW" here even though it's not listed
# as a supported resource_type in the BigQuery DCL doc:
# https://cloud.google.com/bigquery/docs/reference/standard-sql/data-control-language
return "MATERIALIZED VIEW"
return "TABLE"

def _dcl_grants_config_expr(
self,
dcl_cmd: t.Type[DCL],
table: exp.Table,
grants_config: GrantsConfig,
table_type: DataObjectType = DataObjectType.TABLE,
) -> t.List[exp.Expression]:
expressions: t.List[exp.Expression] = []
if not grants_config:
return expressions

# https://cloud.google.com/bigquery/docs/reference/standard-sql/data-control-language

def normalize_principal(p: str) -> str:
if ":" not in p:
raise ValueError(f"Principal '{p}' missing a prefix label")

# allUsers and allAuthenticatedUsers special groups that are cas-sensitive and must start with "specialGroup:"
if p.endswith("allUsers") or p.endswith("allAuthenticatedUsers"):
if not p.startswith("specialGroup:"):
raise ValueError(
f"Special group principal '{p}' must start with 'specialGroup:' prefix label"
)
return p

label, principal = p.split(":", 1)
# always lowercase principals
return f"{label}:{principal.lower()}"

object_kind = self._grant_object_kind(table_type)
for privilege, principals in grants_config.items():
if not principals:
continue

noramlized_principals = [exp.Literal.string(normalize_principal(p)) for p in principals]
args: t.Dict[str, t.Any] = {
"privileges": [exp.GrantPrivilege(this=exp.to_identifier(privilege, quoted=True))],
"securable": table.copy(),
"principals": noramlized_principals,
}

if object_kind:
args["kind"] = exp.Var(this=object_kind)

expressions.append(dcl_cmd(**args)) # type: ignore[arg-type]

return expressions


class _ErrorCounter:
"""
Expand Down
Loading