Skip to content

Commit 9ed6744

Browse files
feat(experimental): add official support for model grants (#5275)
Co-authored-by: Ryan Eakman <6326532+eakmanrq@users.noreply.github.com>
1 parent 26bba97 commit 9ed6744

38 files changed

+4229
-41
lines changed

.circleci/continue_config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ jobs:
148148
command: ./.circleci/test_migration.sh sushi "--gateway duckdb_persistent"
149149
- run:
150150
name: Run the migration test - sushi_dbt
151-
command: ./.circleci/test_migration.sh sushi_dbt "--config migration_test_config"
151+
command: ./.circleci/test_migration.sh sushi_dbt "--config migration_test_config"
152152

153153
ui_style:
154154
docker:

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ dmypy.json
138138
*~
139139
*#
140140

141+
# Vim
142+
*.swp
143+
*.swo
144+
.null-ls*
145+
146+
141147
*.duckdb
142148
*.duckdb.wal
143149

@@ -158,3 +164,4 @@ spark-warehouse/
158164

159165
# claude
160166
.claude/
167+

sqlmesh/core/_typing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
SessionProperties = t.Dict[str, t.Union[exp.Expression, str, int, float, bool]]
1212
CustomMaterializationProperties = t.Dict[str, t.Union[exp.Expression, str, int, float, bool]]
1313

14+
1415
if sys.version_info >= (3, 11):
1516
from typing import Self as Self
1617
else:

sqlmesh/core/engine_adapter/_typing.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@
3030
]
3131

3232
QueryOrDF = t.Union[Query, DF]
33+
GrantsConfig = t.Dict[str, t.List[str]]
34+
DCL = t.TypeVar("DCL", exp.Grant, exp.Revoke)

sqlmesh/core/engine_adapter/base.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from sqlmesh.core.engine_adapter._typing import (
6464
DF,
6565
BigframeSession,
66+
GrantsConfig,
6667
PySparkDataFrame,
6768
PySparkSession,
6869
Query,
@@ -114,6 +115,7 @@ class EngineAdapter:
114115
SUPPORTS_TUPLE_IN = True
115116
HAS_VIEW_BINDING = False
116117
SUPPORTS_REPLACE_TABLE = True
118+
SUPPORTS_GRANTS = False
117119
DEFAULT_CATALOG_TYPE = DIALECT
118120
QUOTE_IDENTIFIERS_IN_VIEWS = True
119121
MAX_IDENTIFIER_LENGTH: t.Optional[int] = None
@@ -2478,6 +2480,33 @@ def wap_publish(self, table_name: TableName, wap_id: str) -> None:
24782480
"""
24792481
raise NotImplementedError(f"Engine does not support WAP: {type(self)}")
24802482

2483+
def sync_grants_config(
2484+
self,
2485+
table: exp.Table,
2486+
grants_config: GrantsConfig,
2487+
table_type: DataObjectType = DataObjectType.TABLE,
2488+
) -> None:
2489+
"""Applies the grants_config to a table authoritatively.
2490+
It first compares the specified grants against the current grants, and then
2491+
applies the diffs to the table by revoking and granting privileges as needed.
2492+
2493+
Args:
2494+
table: The table/view to apply grants to.
2495+
grants_config: Dictionary mapping privileges to lists of grantees.
2496+
table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW).
2497+
"""
2498+
if not self.SUPPORTS_GRANTS:
2499+
raise NotImplementedError(f"Engine does not support grants: {type(self)}")
2500+
2501+
current_grants = self._get_current_grants_config(table)
2502+
new_grants, revoked_grants = self._diff_grants_configs(grants_config, current_grants)
2503+
revoke_exprs = self._revoke_grants_config_expr(table, revoked_grants, table_type)
2504+
grant_exprs = self._apply_grants_config_expr(table, new_grants, table_type)
2505+
dcl_exprs = revoke_exprs + grant_exprs
2506+
2507+
if dcl_exprs:
2508+
self.execute(dcl_exprs)
2509+
24812510
@contextlib.contextmanager
24822511
def transaction(
24832512
self,
@@ -3029,6 +3058,124 @@ def _check_identifier_length(self, expression: exp.Expression) -> None:
30293058
def get_table_last_modified_ts(self, table_names: t.List[TableName]) -> t.List[int]:
30303059
raise NotImplementedError()
30313060

3061+
@classmethod
3062+
def _diff_grants_configs(
3063+
cls, new_config: GrantsConfig, old_config: GrantsConfig
3064+
) -> t.Tuple[GrantsConfig, GrantsConfig]:
3065+
"""Compute additions and removals between two grants configurations.
3066+
3067+
This method compares new (desired) and old (current) GrantsConfigs case-insensitively
3068+
for both privilege keys and grantees, while preserving original casing
3069+
in the output GrantsConfigs.
3070+
3071+
Args:
3072+
new_config: Desired grants configuration (specified by the user).
3073+
old_config: Current grants configuration (returned by the database).
3074+
3075+
Returns:
3076+
A tuple of (additions, removals) GrantsConfig where:
3077+
- additions contains privileges/grantees present in new_config but not in old_config
3078+
- additions uses keys and grantee strings from new_config (user-specified casing)
3079+
- removals contains privileges/grantees present in old_config but not in new_config
3080+
- removals uses keys and grantee strings from old_config (database-returned casing)
3081+
3082+
Notes:
3083+
- Comparison is case-insensitive using casefold(); original casing is preserved in results.
3084+
- Overlapping grantees (case-insensitive) are excluded from the results.
3085+
"""
3086+
3087+
def _diffs(config1: GrantsConfig, config2: GrantsConfig) -> GrantsConfig:
3088+
diffs: GrantsConfig = {}
3089+
cf_config2 = {k.casefold(): {g.casefold() for g in v} for k, v in config2.items()}
3090+
for key, grantees in config1.items():
3091+
cf_key = key.casefold()
3092+
3093+
# Missing key (add all grantees)
3094+
if cf_key not in cf_config2:
3095+
diffs[key] = grantees.copy()
3096+
continue
3097+
3098+
# Include only grantees not in config2
3099+
cf_grantees2 = cf_config2[cf_key]
3100+
diff_grantees = []
3101+
for grantee in grantees:
3102+
if grantee.casefold() not in cf_grantees2:
3103+
diff_grantees.append(grantee)
3104+
if diff_grantees:
3105+
diffs[key] = diff_grantees
3106+
return diffs
3107+
3108+
return _diffs(new_config, old_config), _diffs(old_config, new_config)
3109+
3110+
def _get_current_grants_config(self, table: exp.Table) -> GrantsConfig:
3111+
"""Returns current grants for a table as a dictionary.
3112+
3113+
This method queries the database and returns the current grants/permissions
3114+
for the given table, parsed into a dictionary format. The it handles
3115+
case-insensitive comparison between these current grants and the desired
3116+
grants from model configuration.
3117+
3118+
Args:
3119+
table: The table/view to query grants for.
3120+
3121+
Returns:
3122+
Dictionary mapping permissions to lists of grantees. Permission names
3123+
should be returned as the database provides them (typically uppercase
3124+
for standard SQL permissions, but engine-specific roles may vary).
3125+
3126+
Raises:
3127+
NotImplementedError: If the engine does not support grants.
3128+
"""
3129+
if not self.SUPPORTS_GRANTS:
3130+
raise NotImplementedError(f"Engine does not support grants: {type(self)}")
3131+
raise NotImplementedError("Subclass must implement get_current_grants")
3132+
3133+
def _apply_grants_config_expr(
3134+
self,
3135+
table: exp.Table,
3136+
grants_config: GrantsConfig,
3137+
table_type: DataObjectType = DataObjectType.TABLE,
3138+
) -> t.List[exp.Expression]:
3139+
"""Returns SQLGlot Grant expressions to apply grants to a table.
3140+
3141+
Args:
3142+
table: The table/view to grant permissions on.
3143+
grants_config: Dictionary mapping permissions to lists of grantees.
3144+
table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW).
3145+
3146+
Returns:
3147+
List of SQLGlot expressions for grant operations.
3148+
3149+
Raises:
3150+
NotImplementedError: If the engine does not support grants.
3151+
"""
3152+
if not self.SUPPORTS_GRANTS:
3153+
raise NotImplementedError(f"Engine does not support grants: {type(self)}")
3154+
raise NotImplementedError("Subclass must implement _apply_grants_config_expr")
3155+
3156+
def _revoke_grants_config_expr(
3157+
self,
3158+
table: exp.Table,
3159+
grants_config: GrantsConfig,
3160+
table_type: DataObjectType = DataObjectType.TABLE,
3161+
) -> t.List[exp.Expression]:
3162+
"""Returns SQLGlot expressions to revoke grants from a table.
3163+
3164+
Args:
3165+
table: The table/view to revoke permissions from.
3166+
grants_config: Dictionary mapping permissions to lists of grantees.
3167+
table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW).
3168+
3169+
Returns:
3170+
List of SQLGlot expressions for revoke operations.
3171+
3172+
Raises:
3173+
NotImplementedError: If the engine does not support grants.
3174+
"""
3175+
if not self.SUPPORTS_GRANTS:
3176+
raise NotImplementedError(f"Engine does not support grants: {type(self)}")
3177+
raise NotImplementedError("Subclass must implement _revoke_grants_config_expr")
3178+
30323179

30333180
class EngineAdapterWithIndexSupport(EngineAdapter):
30343181
SUPPORTS_INDEXES = True

sqlmesh/core/engine_adapter/base_postgres.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def columns(
6262
raise SQLMeshError(
6363
f"Could not get columns for table '{table.sql(dialect=self.dialect)}'. Table not found."
6464
)
65+
6566
return {
6667
column_name: exp.DataType.build(data_type, dialect=self.dialect, udt=True)
6768
for column_name, data_type in resp
@@ -196,3 +197,10 @@ def _get_data_objects(
196197
)
197198
for row in df.itertuples()
198199
]
200+
201+
def _get_current_schema(self) -> str:
202+
"""Returns the current default schema for the connection."""
203+
result = self.fetchone(exp.select(exp.func("current_schema")))
204+
if result and result[0]:
205+
return result[0]
206+
return "public"

sqlmesh/core/engine_adapter/bigquery.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sqlmesh.core.engine_adapter.base import _get_data_object_cache_key
1212
from sqlmesh.core.engine_adapter.mixins import (
1313
ClusteredByMixin,
14+
GrantsFromInfoSchemaMixin,
1415
RowDiffMixin,
1516
TableAlterClusterByOperation,
1617
)
@@ -40,7 +41,7 @@
4041
from google.cloud.bigquery.table import Table as BigQueryTable
4142

4243
from sqlmesh.core._typing import SchemaName, SessionProperties, TableName
43-
from sqlmesh.core.engine_adapter._typing import BigframeSession, DF, Query
44+
from sqlmesh.core.engine_adapter._typing import BigframeSession, DCL, DF, GrantsConfig, Query
4445
from sqlmesh.core.engine_adapter.base import QueryOrDF
4546

4647

@@ -55,7 +56,7 @@
5556

5657

5758
@set_catalog()
58-
class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin):
59+
class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin, GrantsFromInfoSchemaMixin):
5960
"""
6061
BigQuery Engine Adapter using the `google-cloud-bigquery` library's DB API.
6162
"""
@@ -65,6 +66,11 @@ class BigQueryEngineAdapter(ClusteredByMixin, RowDiffMixin):
6566
SUPPORTS_TRANSACTIONS = False
6667
SUPPORTS_MATERIALIZED_VIEWS = True
6768
SUPPORTS_CLONING = True
69+
SUPPORTS_GRANTS = True
70+
CURRENT_USER_OR_ROLE_EXPRESSION: exp.Expression = exp.func("session_user")
71+
SUPPORTS_MULTIPLE_GRANT_PRINCIPALS = True
72+
USE_CATALOG_IN_GRANTS = True
73+
GRANT_INFORMATION_SCHEMA_TABLE_NAME = "OBJECT_PRIVILEGES"
6874
MAX_TABLE_COMMENT_LENGTH = 1024
6975
MAX_COLUMN_COMMENT_LENGTH = 1024
7076
SUPPORTS_QUERY_EXECUTION_TRACKING = True
@@ -1326,6 +1332,108 @@ def _session_id(self) -> t.Any:
13261332
def _session_id(self, value: t.Any) -> None:
13271333
self._connection_pool.set_attribute("session_id", value)
13281334

1335+
def _get_current_schema(self) -> str:
1336+
raise NotImplementedError("BigQuery does not support current schema")
1337+
1338+
def _get_bq_dataset_location(self, project: str, dataset: str) -> str:
1339+
return self._db_call(self.client.get_dataset, dataset_ref=f"{project}.{dataset}").location
1340+
1341+
def _get_grant_expression(self, table: exp.Table) -> exp.Expression:
1342+
if not table.db:
1343+
raise ValueError(
1344+
f"Table {table.sql(dialect=self.dialect)} does not have a schema (dataset)"
1345+
)
1346+
project = table.catalog or self.get_current_catalog()
1347+
if not project:
1348+
raise ValueError(
1349+
f"Table {table.sql(dialect=self.dialect)} does not have a catalog (project)"
1350+
)
1351+
1352+
dataset = table.db
1353+
table_name = table.name
1354+
location = self._get_bq_dataset_location(project, dataset)
1355+
1356+
# https://cloud.google.com/bigquery/docs/information-schema-object-privileges
1357+
# OBJECT_PRIVILEGES is a project-level INFORMATION_SCHEMA view with regional qualifier
1358+
object_privileges_table = exp.to_table(
1359+
f"`{project}`.`region-{location}`.INFORMATION_SCHEMA.{self.GRANT_INFORMATION_SCHEMA_TABLE_NAME}",
1360+
dialect=self.dialect,
1361+
)
1362+
return (
1363+
exp.select("privilege_type", "grantee")
1364+
.from_(object_privileges_table)
1365+
.where(
1366+
exp.and_(
1367+
exp.column("object_schema").eq(exp.Literal.string(dataset)),
1368+
exp.column("object_name").eq(exp.Literal.string(table_name)),
1369+
# Filter out current_user
1370+
# BigQuery grantees format: "user:email" or "group:name"
1371+
exp.func("split", exp.column("grantee"), exp.Literal.string(":"))[
1372+
exp.func("OFFSET", exp.Literal.number("1"))
1373+
].neq(self.CURRENT_USER_OR_ROLE_EXPRESSION),
1374+
)
1375+
)
1376+
)
1377+
1378+
@staticmethod
1379+
def _grant_object_kind(table_type: DataObjectType) -> str:
1380+
if table_type == DataObjectType.VIEW:
1381+
return "VIEW"
1382+
if table_type == DataObjectType.MATERIALIZED_VIEW:
1383+
# We actually need to use "MATERIALIZED VIEW" here even though it's not listed
1384+
# as a supported resource_type in the BigQuery DCL doc:
1385+
# https://cloud.google.com/bigquery/docs/reference/standard-sql/data-control-language
1386+
return "MATERIALIZED VIEW"
1387+
return "TABLE"
1388+
1389+
def _dcl_grants_config_expr(
1390+
self,
1391+
dcl_cmd: t.Type[DCL],
1392+
table: exp.Table,
1393+
grants_config: GrantsConfig,
1394+
table_type: DataObjectType = DataObjectType.TABLE,
1395+
) -> t.List[exp.Expression]:
1396+
expressions: t.List[exp.Expression] = []
1397+
if not grants_config:
1398+
return expressions
1399+
1400+
# https://cloud.google.com/bigquery/docs/reference/standard-sql/data-control-language
1401+
1402+
def normalize_principal(p: str) -> str:
1403+
if ":" not in p:
1404+
raise ValueError(f"Principal '{p}' missing a prefix label")
1405+
1406+
# allUsers and allAuthenticatedUsers special groups that are cas-sensitive and must start with "specialGroup:"
1407+
if p.endswith("allUsers") or p.endswith("allAuthenticatedUsers"):
1408+
if not p.startswith("specialGroup:"):
1409+
raise ValueError(
1410+
f"Special group principal '{p}' must start with 'specialGroup:' prefix label"
1411+
)
1412+
return p
1413+
1414+
label, principal = p.split(":", 1)
1415+
# always lowercase principals
1416+
return f"{label}:{principal.lower()}"
1417+
1418+
object_kind = self._grant_object_kind(table_type)
1419+
for privilege, principals in grants_config.items():
1420+
if not principals:
1421+
continue
1422+
1423+
noramlized_principals = [exp.Literal.string(normalize_principal(p)) for p in principals]
1424+
args: t.Dict[str, t.Any] = {
1425+
"privileges": [exp.GrantPrivilege(this=exp.to_identifier(privilege, quoted=True))],
1426+
"securable": table.copy(),
1427+
"principals": noramlized_principals,
1428+
}
1429+
1430+
if object_kind:
1431+
args["kind"] = exp.Var(this=object_kind)
1432+
1433+
expressions.append(dcl_cmd(**args)) # type: ignore[arg-type]
1434+
1435+
return expressions
1436+
13291437

13301438
class _ErrorCounter:
13311439
"""

0 commit comments

Comments
 (0)