Inherited grants, plus the sync-mode grant correctness fixes found applying them - #58
Conversation
…at risk Snowflake gives schema-level future grants precedence over database-level future grants on the same object type, and silently ignores the database-level grant for that schema. Objects created there never receive the privilege and nothing fails, so the misconfiguration only surfaces as a user reporting missing access. Managed access schemas are where this bites hardest: privilege management is centralized on the schema owner, so the schema-level grant that shadows a database-level one is typically added by a different config than the one that declared it. `snowcap plan` now warns in two cases: - a schema-level future grant already shadows a database-level future grant on the same object type (a live misconfiguration) - a database contains managed access schemas and relies on database-level future grants (one schema-level grant away from breaking) The check runs over the manifest plus remote state so it sees steady-state config, not just the changes in the plan, and falls back to the plan's contents for `snowcap apply --plan plan.json`. Also documents the trap and the schema-level fix in the RBAC pattern docs, whose example config demonstrates the database-level form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nsDw37awLyZU91EARPpap
Snowflake's inherited grants (GRANT INHERITED <priv> ON ALL <type> IN <container>, preview, enabled with FEATURE_RBAC_INHERITED_GRANTS) are container-level grants covering every current and future object of a type. Snowflake reports them in SHOW GRANTS and ACCOUNT_USAGE.GRANTS_TO_ROLES with IS_INHERITED set and an empty NAME. Snowcap read those rows as ordinary object grants. In an account with the feature enabled that produced grants with no object name in remote state, which grant sync mode would try to remove with a per-object REVOKE — invalid SQL for a grant that can only be removed with REVOKE INHERITED — and which `snowcap export` would write into YAML that cannot be applied. Inherited grant rows are now filtered out of every path that reads grants, so Snowcap neither manages nor disturbs them: - ACCOUNT_USAGE.GRANTS_TO_ROLES now selects IS_INHERITED and drops inherited rows. Snowflake versions whose view lacks the column are detected once and queried without it, rather than falling back to per-role SHOW GRANTS. - SHOW GRANTS TO ROLE / DATABASE ROLE and SHOW GRANTS ON ACCOUNT drop them too, which covers list_grants, fetch_grant, and fetch_role_privileges. Also fixes ownership transfers for owner-executed objects. Enabling the feature tightens GRANT OWNERSHIP on objects that run with their owner's privileges (views, tasks, procedures, pipes, policies, Streamlit apps, and so on): it now fails unless the receiving role is in the caller's active role hierarchy or the caller holds account-level MANAGE GRANTS. Snowcap ran those transfers as the outgoing owner, which satisfies neither condition in the common case, so it now prefers SECURITYADMIN when the session has it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nsDw37awLyZU91EARPpap
GitHub now forces Node 20 actions onto Node 24 and reports each one as deprecated. Three of the pinned actions still target Node 20: - codecov/codecov-action@v5 pins actions/github-script v7.0.1 internally, which is what the "Run Tests" job reports. v6 pins v8.0.0 (Node 24) and takes no input changes; it also stops interpolating inputs directly into shell scripts. - actions/checkout@v4 -> v5, matching the other workflows in the repo. - actions/upload-artifact@v4 -> v6, which is input-compatible and differs only in its runtime. actions/download-artifact stays on v4: it has no Node 24 release yet, so a bump would not clear the warning. Noted inline so the mismatch does not read as an oversight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nsDw37awLyZU91EARPpap
`make style` was drifting: black was unpinned, so its output changed between
releases and 32 files were left unformatted under any recent version. Running
it produced a large unrelated diff on top of whatever change was in flight,
which meant nobody ran it.
- Pin black, and apply it across the repo so the tree is clean.
- Extend the black exclude list to cover local venv and build directories, so
`make style` cannot walk into a virtualenv.
- Fix the spelling errors codespell reports, and repair its config: the
ignore-words-list and skip entries were TOML arrays, which codespell
silently ignores, so neither list had any effect. Deliberately malformed
identifiers in parser and error-handling tests are now listed explicitly.
- Add a read-only `make lint` (black --check, codespell, ruff) and run it in
CI, so the tree stays clean instead of drifting again.
No behavior changes: the only edits outside comments and docstrings are
formatting and two error message strings ("paramters" -> "parameters",
"cant" -> "can't").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nsDw37awLyZU91EARPpap
An inherited grant is a single grant on a container -- account, database, or
schema -- that applies to every current and future object of a type inside
it, replacing the ALL + FUTURE pair that a container-wide privilege needs
today.
grants:
- priv: SELECT
on: INHERITED TABLES IN DATABASE sales_db
to: analyst
Expressing them:
- `on: "INHERITED <objects> IN <container>"`, the list forms, and
`inherited: true` to upgrade an existing grant on all objects in place.
- The account container, which only inherited grants can be scoped to and
which has no name of its own, so it needed a new encoding in the URN.
- `GRANT INHERITED` / `REVOKE INHERITED` emission, and `from_sql` parsing.
- Validation at plan time for the combinations Snowflake rejects: WITH GRANT
OPTION, OWNERSHIP, priv ALL, shares and integrations, USAGE on roles and
users, and containers other than account, database, or schema.
- A probe of FEATURE_RBAC_INHERITED_GRANTS, so a config declaring inherited
grants against an account that has not opted in fails before apply rather
than partway through it. An unreadable parameter never blocks a run.
Reading them back:
- Inherited grants are matched to the container they were created on via the
INHERITED_FROM columns, from SHOW GRANTS on small accounts and from one
GRANTS_TO_ROLES query on large ones. Both come from the response Snowcap
already fetches per role, so drift detection costs no extra queries.
- Unlike ON ALL grants, which are reapplied on every run because they cannot
be compared, an inherited grant that already exists produces no plan
changes.
- list_grants reports them, so grant sync mode manages them and `snowcap
export` writes them back out.
Two related fixes:
- Per-object grants covered by a declared collection grant are no longer
dropped. The existing check only recognized schema-level ALL grants, and
when a manifest held any ALL grant it silently skipped dropping every
unmanaged object grant. Coverage is now matched by privilege, grantee,
object type, and container for both ALL and INHERITED grants.
- Inherited grants require MANAGE GRANTS on the container, which is how
Snowflake delegates access management to a database or schema admin. An
explicit `owner` on an inherited grant now issues it as that role instead
of SECURITYADMIN. Other grants are unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nsDw37awLyZU91EARPpap
The account parameter that opts into inherited grants can be declared like
any other:
account_parameters:
- name: FEATURE_RBAC_INHERITED_GRANTS
value: ENABLED
That already produced the right SQL, but two things stopped it working
end to end.
Ordering: an account parameter and a grant are both account-scoped with
nothing linking them, so they landed at the same level of the plan and ran
concurrently -- the grants could reach Snowflake before the parameter did.
Inherited grants now take a reference on the parameter when the config
declares it, which orders the apply. The reference is conditional, since a
reference to a resource that is not in the manifest is an error of its own,
and it is not added to ON ALL grants, which do not need the preview.
Plan gate: the check added with inherited grant support refused to plan when
the account had not opted in, which is true by definition on the run that
opts it in. It now skips the check when the config enables the parameter
itself, and its error message offers the account_parameters form alongside
the ALTER ACCOUNT statement.
Docs now lead with the parameter rather than the raw SQL, and note that
enabling preview features for the account remains a prior step Snowcap
cannot do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nsDw37awLyZU91EARPpap
The previous note said enabling preview features was a required prior step Snowcap could not perform. That overstated it: preview access is enabled by default for most accounts, so there is usually nothing to do, and the only thing Snowcap genuinely cannot manage is the account-wide preview toggle -- SYSTEM$ENABLE_PREVIEW_ACCESS is a system function rather than a resource, so it has no declarative form. The inherited grants parameter itself is an ordinary account parameter and is fully managed by Snowcap, so the docs now lead with that and mention the account-wide toggle only as the rare case. The plan-time error now distinguishes the two. When preview access is off, setting the parameter will not help, so it names the function to call instead of suggesting config that would silently fail to take effect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nsDw37awLyZU91EARPpap
`GRANT IMPORTED PRIVILEGES ON DATABASE <shared_db> TO ROLE <r>` is one statement, but Snowflake fans it out in SHOW GRANTS into a row per object the share exposes: every view, function, procedure, schema, database role, class, tag and image repository, plus a USAGE row on the database itself. On the SNOWFLAKE shared database that is several hundred rows. None of those rows can appear in the manifest, since config declares the single IMPORTED PRIVILEGES grant rather than the fan-out. Sync therefore read them as undeclared grants and revoked them, undoing the access the declared grant had just handed out. A plan against a config using shared databases would create the IMPORTED PRIVILEGES grant and revoke everything it produces in the same run. This was latent until the grant-coverage rewrite. The previous drop loop bound its `else` to the outer condition, so whenever the manifest held any ALL grant, every unmatched remote OBJECT grant fell through both branches and was never dropped -- masking this for any config that used both. Match on grantee and containment, deliberately not on privilege: the fan-out rows carry whatever privilege each object type takes, never "IMPORTED PRIVILEGES". Containment alone is safe because the privilege is only grantable on a shared database, and objects in a shared database cannot be granted independently -- the share is their only source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JpNXWZiUkvEovKPvCVdQre
Snowflake creates a cortex agent server object when an account connects an
MCP client (Claude, Cursor, etc.). Its grants appear in SHOW GRANTS with
granted_on 'CORTEX_AGENT_SERVER', so they land in remote state whether or
not anyone declared the object.
ResourceType had no member for it, so reading that remote state aborted the
whole run with:
Expected Grant.on_type to be one of (...), got 'CORTEX AGENT SERVER'
Register the type the same way CORTEX SEARCH SERVICE and DBT PROJECT are
handled: an enum member, a SchemaScope in RESOURCE_SCOPES, and a priv class.
There is no concrete resource class -- the object is created out of band by
Snowflake -- but this lets its grants be read, and lets users write
`priv: USAGE on cortex agent server <db>.<schema>.<name>` to manage access.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
fetch_remote_state builds a grant via resource_cls.spec(**data), which bypasses Grant.__init__ and its OWNERSHIP rejection. That is the path that aborted plan, since Snowflake reports the cortex agent server it creates for an MCP client as an OWNERSHIP grant to ACCOUNTADMIN. Exercise the spec directly so the regression test matches the real traceback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
The previous commit registered CORTEX AGENT SERVER as its own ResourceType. That was wrong in two ways, both found by running plan against a real account. It is not a distinct object. SHOW MCP SERVERS lists ADMIN_DB.MCPS.DATACOVES with a created_on matching its CORTEX_AGENT_SERVER ownership grant to the millisecond. Snowflake reports grants on an MCP server with granted_on 'CORTEX_AGENT_SERVER' but lists and creates it as an MCP server. And the DDL grammar has no such object: GRANT ... ON CORTEX AGENT SERVER fails with a syntax error, so the generated statement would have been rejected at apply time. Registering a scope for it also made fetch_remote_state look for data_provider.fetch_cortex_agent_server, which does not exist, so plan aborted with an AttributeError. Map the grant-side spelling onto MCP_SERVER instead, which already has a resource class, a schema scope, a priv set and a fetch function. Drops the enum member, the invented priv class and the scope entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
for_each takes a var reference and nothing else, so a block always covers a whole list. That forces a second list whenever a block should cover only part of one -- most often when granting on a clone of a database, where the same schema list drives the source and the copy but other databases in the list do not have a copy. Add an optional `where` expression, evaluated per item as bare Jinja via compile_expression, skipping items it is falsy for. One list can now drive several blocks that each cover a subset. This matters for cloned environments: CREATE DATABASE ... CLONE copies grants on child objects, so each z_schema__<name> role already reaches the clone's schemas. Snowcap could not say so without restating every schema, and sync revoked them -- which silently flattens a tiered model, since a role scoped to L3 in the source had its clone access removed along with everything else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
The RBAC guide covered no clone case, though it is where anyone designing roles for a QA or blue-green environment would look. Its role-type table also says account-level roles are not included in clones, which is true of the roles but omits that grants to them on child objects are copied -- the detail that makes a clone appear as undeclared remote state. Records what a clone does to grants, why sync proposes dropping the copied ones, the filtered for_each that declares them, and why a database-wide grant is the wrong shortcut: it flattens layer-scoped roles in the clone while the source still looks right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
A plan that adopts an existing database both transfers it and creates resources inside it. Applying one failed: Insufficient privileges to operate on database 'GREAT_BAY_DEV'. Your primary role ANALYST must have CREATE DATABASE ROLE granted on DATABASE GREAT_BAY_DEV. on CREATE DATABASE ROLE GREAT_BAY_DEV.DR_WRITER_ROLE The ordering was right. execution_strategy_for_change runs a database- or schema-scoped CREATE as the container's owner, taken from change.container, which is recorded when the plan is built. Containers sit at a lower dependency level than their contents, so when the same plan transfers the container, that transfer has already run by the time the CREATE executes -- and the role recorded in the plan no longer owns the container, so it cannot create anything in it. Snowcap issued USE ROLE ANALYST for a database ANALYST had just stopped owning. compile_plan_to_sql now collects the containers the plan hands to a new owner and passes them down, so the CREATE runs as the owner the container ends up with. The mapping is derived from the plan itself; callers do not need to know a transfer happened, and the new parameter is optional so existing callers are unaffected. This is self-healing on a second apply, since by then the transfer is committed and the plan reads the new owner. It bites the first apply that adopts an existing database, which is exactly when a user is least able to tell a tool bug from a misconfiguration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
Applying a config that grants a database role to an account role failed: Unsupported feature 'GRANT/REVOKE USAGE ON DATABASE_ROLE'. on REVOKE USAGE ON DATABASE ROLE GREAT_BAY_DEV.DR_READER_ROLE FROM ROLE GREAT_BAY_DEV__READER Snowflake reports a granted role as a grant held by the grantee, so SHOW GRANTS and ACCOUNT_USAGE return role-to-role rows alongside object grants. list_grants skipped rows with granted_on = ROLE, since list_role_grants covers those, but not rows with granted_on = DATABASE ROLE, which list_database_role_grants covers. So one Snowflake fact was read back under two resource types. The declared DatabaseRoleGrant matched the DatabaseRoleGrant, and the stray Grant matched nothing, so with grant in --sync_resources every plan proposed dropping a grant the same plan had just created. The config could never converge. The drop was also unrunnable. A Grant revokes with REVOKE <priv> ON <on_type>, which reads REVOKE USAGE ON DATABASE ROLE and is not valid Snowflake syntax; the revoke a database role takes is REVOKE DATABASE ROLE <name> FROM ROLE <grantee>, which drop_database_role_grant already builds. Because it is a SQL compilation error rather than a permissions one, it aborts the destructive phase of the apply outright. Both listing paths are fixed, ACCOUNT_USAGE and the SHOW GRANTS fallback, through one predicate. It normalizes the separator: ACCOUNT_USAGE spells the type DATABASE_ROLE and SHOW GRANTS spells it DATABASE ROLE, and the existing skip in the database-role branch compared against the spaced form only, so it missed the ACCOUNT_USAGE spelling. Normalizing is by separator rather than by substring so an unrelated underscored type such as MCP_SERVER is not caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
Applying a plan that reaped stale access to a shared database failed: Revoking individual privileges on imported database is not allowed. Use 'REVOKE IMPORTED PRIVILEGES' on REVOKE USAGE ON DATABASE WORLDWIDE_ADDRESS_DATA FROM ROLE TRANSFORMER_DBT Privileges on a shared database are handed out by one statement, GRANT IMPORTED PRIVILEGES ON DATABASE <db>, and reported by SHOW GRANTS as a row per object the share exposes: USAGE on the database, USAGE on each schema, SELECT on each view. Snowcap lists those rows as ordinary Grants, which is right -- when no declared IMPORTED PRIVILEGES grant covers them they are stale access and sync should reap them, as _covered_by_imported_privileges and its tests already establish. The intent was correct; only the SQL was wrong. drop_grant built REVOKE <priv> ON <on_type>, which Snowflake rejects for anything a share provides. Since the share is the only source of privileges on those objects, revoking IMPORTED PRIVILEGES removes the whole fan-out for that grantee, so every row maps to the same statement. It is idempotent: the first takes the access away and repeats find nothing left. Like the database role revoke, this failed as a SQL compilation error rather than a permissions one, so it aborted the destructive phase outright. Both fired 14ms apart in the same run through the thread pool, which is why only one of them tended to surface. Shared database names come from the SHOW DATABASES response list_databases has already cached, so the lookup costs no extra query, and it is skipped entirely unless the plan revokes a grant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
…RANTS reports A plan that declared USAGE on an MCP server both created and dropped the same grant on every run: - DROP: USAGE on MCP SERVER.ADMIN_DB.MCPS.DATACOVES -> ROLE.Z_MCP__DATACOVES + CREATE: USAGE on MCP SERVER.ADMIN_DB.MCPS.DATACOVES -> ROLE.Z_MCP__DATACOVES Snowflake reports a grant on an MCP server with granted_on CORTEX_AGENT_SERVER, while GRANT and CREATE call the object an MCP SERVER. ResourceType already maps that synonym and grant_fqn runs the manifest side through resource_label_for_type, but list_grants built the URN straight from the raw string. Remote state therefore identified the grant as cortex_agent_server/... and the manifest as mcp_server/..., so the declared grant never matched the one read back. That is worse than churn. Creates run in the additive phase and drops in the destructive one, so applying the plan granted the privilege and then revoked it, leaving the config's declared access removed after a successful apply. All three URL-building sites in list_grants now go through resource_label_for_type, the same function the manifest uses, so the two sides agree by construction rather than by coincidence. ResourceType spells its members with spaces where Snowflake uses underscores, hence the substitution. For every non-synonym type the result is identical to the previous granted_on.lower(), and an object type ResourceType does not know still falls back to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
Five revokes reported SUCCESS and left the privilege in place, so the same drops came back in every subsequent plan: REVOKE CREATE DATABASE ON ACCOUNT FROM ROLE TRANSFORMER_DBT An account-level privilege belongs to a system role -- CREATE DATABASE to SYSADMIN, granted_by SYSADMIN in the account. Snowflake will not take one back from a role that does not own it, and rather than failing it reports success, so nothing in the apply output says the revoke did nothing. Confirmed against a live account: the same statement run as ACCOUNTADMIN removed the grant that a SECURITYADMIN-run revoke had claimed to remove minutes earlier. execution_strategy_for_change already consulted system_role_for_priv, but only for CreateResource, so grants went to the right role while revokes of the very same privilege fell through to SECURITYADMIN. That asymmetry is the bug: a privilege snowcap can grant should be a privilege snowcap can take back. Drops now resolve their role the same way, reading priv from change.before. Also adds CREATE OPENFLOW DATA PLANE INTEGRATION to AccountPriv, owned by ACCOUNTADMIN. Snowcap did not know the privilege at all, so system_role_for_priv returned None and the revoke fell through to SECURITYADMIN even with the fix above. Object grants are unaffected: only account-level privileges have an owning system role, and a revoke keeps using SECURITYADMIN when the owning role is not available to the session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
Three revokes reported SUCCESS and left the grant in place, so the same drops came back in every subsequent plan: REVOKE USAGE ON DATABASE GREAT_BAY FROM DATABASE ROLE GREAT_BAY.DR_CREATE_ROLE A database role is named <database>.<role> and lives inside its database. SECURITYADMIN held account-level MANAGE GRANTS, which is what lets it administer grants, but had no USAGE on GREAT_BAY -- it had USAGE on BALBOA, BALBOA_APPS, BALBOA_DEV and RAW, and nothing on GREAT_BAY. Without that it cannot resolve the grantee, and REVOKE reports success rather than failing on a grantee it cannot resolve, so the grant survives with nothing in the apply output to say why. Grants held by a database role now run as the role that owns the database, the same container-owner rule execution_strategy_for_change already applies to database- and schema-scoped creates. Applied to grants as well as revokes: the asymmetry between the two was the previous bug in this area, and repeating it here would leave grants to database roles depending on whichever role happened to have usage. Grants to account roles are untouched, since account-level authority does reach them, and both new lookups fall back to SECURITYADMIN when the owning role is not available to the session or the database is unknown. The owner map comes from the SHOW DATABASES response list_databases has already cached, which is the same response the shared-database lookup reads, so the two together still cost one query -- and both are skipped unless the plan touches a grant at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
Apply treated "no exception" as "applied". Snowflake does not always fail a
statement it could not carry out -- REVOKE reports success when the executing
role does not own the privilege or cannot resolve the grantee -- so a drop could
be counted as applied while the grant survived. The same drop then returned in
every later plan with nothing in any output saying why.
Two of the bugs fixed on this branch had that shape, and neither was visible from
snowcap alone. Finding them meant reading Snowflake's query history against live
state and noticing that statements logged as SUCCESS had changed nothing. That is
not a reasonable thing to ask of anyone running apply.
Apply now reads back the resources it dropped and prints any that are still
there, formatted the way the plan formats the same change so the line matches
what the user saw under DROP:
! 2 drop(s) reported success but the resource is still there:
USAGE on DATABASE.GREAT_BAY -> DATABASE ROLE.GREAT_BAY.DR_CREATE_ROLE
CREATE DATABASE on ACCOUNT.ACCOUNT -> ROLE.TRANSFORMER_DBT
Printed rather than logged: a warning scrolls past mid-apply, and these are
precisely the failures nothing tells you about. It reports rather than raises,
since the apply did everything it was asked to and the remedy is a privilege
question outside snowcap's control.
Costs one existence check per dropped resource, skipped entirely when a plan
dropped nothing, and reads through reset_cache since the apply just changed the
state being read. A resource type that cannot be read back is skipped rather than
reported -- not being able to confirm a drop is not evidence that it failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
…he database" This reverts 43fdd31, temporarily and on purpose. That fix makes the three GREAT_BAY database role grants revoke successfully, which leaves nothing for the drop verification added in 29da904 to report. Backing it out restores the silent-failure condition so the new warning can be seen firing against a real account rather than only in tests. Restore with: git revert <this commit> Nothing else depends on it: the verification in 29da904 is untouched, and the six tests that came with the fix are reverted alongside it, so the suite stays green at 1970. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
…the database" This reverts commit 92110a6.
Three drops survived every apply, first as SECURITYADMIN and then, after 43fdd31, as the database owner itself: [gomezn:TRANSFORMER_DBT] REVOKE USAGE ON DATABASE GREAT_BAY FROM DATABASE ROLE GREAT_BAY.DR_CREATE_ROLE -> SUCCESS The grant was never revokable. Creating a database role gives it USAGE on the database it belongs to; Snowflake reports that in SHOW GRANTS like any other grant, but with an empty granted_by and a timestamp matching the CREATE, because no role granted it. It is part of the role existing, the way OWNERSHIP is, and nothing takes it away. GREAT_BAY_DEV shows both rows side by side, which is what made it legible: 1786897587.319 USAGE DATABASE GREAT_BAY_DEV DR_READER_ROLE "" 1786897715.538 USAGE DATABASE GREAT_BAY_DEV DR_READER_ROLE "TRANSFORMER_DBT" The first is intrinsic, stamped with the CREATE DATABASE ROLE. The second is an explicit grant of the same privilege. GREAT_BAY's roles had only the first, so every revoke reported success and changed nothing, and sync proposed the same three drops on every run. Listing it puts a row in remote state that no config can declare away and no apply can remove. Both listing paths now skip it, alongside OWNERSHIP, which it resembles. The explicit row is indistinguishable from the intrinsic one once reduced to a grant URN, so both are skipped: a declared usage on a database role's own database re-grants each apply, which is harmless because the role already has it. This supersedes the reasoning in 43fdd31. That commit made these revokes run as the database owner on the theory that SECURITYADMIN could not resolve a grantee inside a database it lacked usage on. The theory was wrong -- the owner cannot revoke this either -- though running database role grants as the owner is defensible on its own terms, so it stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
DatabaseRoleGrant has always accepted to_database_role, its docstring has always documented the YAML form, and both create_sql and drop_database_role_grant render it correctly: GRANT DATABASE ROLE DB.CHILD TO DATABASE ROLE DB.PARENT REVOKE DATABASE ROLE DB.CHILD FROM DATABASE ROLE DB.PARENT The gitops loader read to_role and roles only. An entry using the documented to_database_role therefore produced no resource and no complaint: the grant was simply absent from the plan, which is the worst way for a config to be wrong. The loader now reads both, in singular and plural. `roles` keeps its existing meaning as the plural of to_role, `database_roles` is the matching plural of to_database_role, and one entry may use both. Structure is validated the way role_grants already validates it, so an unknown key is reported with a suggestion, an entry with no database_role is rejected, and an entry that grants to nothing is an error rather than silence. A key present but null counts as absent. That is how YAML spells "not specified", and serialized configs round-trip unset fields as explicit nulls -- the database_role_grant JSON fixture carries "to_database_role": null alongside a real to_role, and testing for the key rather than the value read it as a request to grant to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
…eclares them Migrating balboa from ALL plus FUTURE pairs to inherited grants produced a plan with 38 creates and zero drops, leaving 26 future grants orphaned in Snowflake. Three problems, one root cause. 1. Future grants were invisible to sync fetch_remote_state skipped SHOW FUTURE GRANTS when the manifest declared no future grants, and narrowed it to roles named in the manifest when it did. Sound for a plan that only creates; wrong for sync, which exists to remove what config does not declare, so a future grant absent from config is exactly what has to be found. Removing the last future grant from a config -- which is what migrating to inherited grants does -- put the existing ones beyond reach: unseen rather than deliberately kept, with nothing in the plan to say so. Future grants are now always read when grants are synced, for every role. manifest_has_future_grants and manifest_future_grant_roles lose their only callers but are left in place; they are correct, just not safe to optimise sync with. 2. Share-backed databases were identified by the wrong privilege GRANT IMPORTED PRIVILEGES ON DATABASE <db> is how access to a shared database is given, and Snowflake reports the result as plain USAGE. list_grants recorded USAGE, so the declared grant never matched what was read back and every plan proposed creating it again -- forever, and invisibly, since re-granting changes nothing. fetch_grant already resolved this, but syncing a resource type builds remote state from list_* alone and discards the manifest URNs, so that path never ran for a synced grant. Also widens the test for share-backed from IMPORTED DATABASE to any kind but STANDARD, in both paths. The SNOWFLAKE database is APPLICATION and behaves the same way; a STANDARD database is the only case where a plain USAGE grant could be mistaken for this one. 3. fetch_grant compared object types as raw strings So a grant Snowflake reports under a different name than its DDL uses -- CORTEX_AGENT_SERVER for an MCP SERVER -- never matched. 8488b49 fixed this for list_grants and missed the fetch path, which is why the spurious drop went away and the spurious create stayed. All three are the same shape as the bugs already fixed on this branch: remote state describing a grant differently from the config that declares it. Five existing list_grants tests gain a SHOW DATABASES branch, since resolving share-backed databases is one more query on a cached response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
Review — no comment produced this roundThe review job finished without producing findings (job log). A maintainer may want to re-run it. |
# Conflicts: # snowcap/data_provider.py
- blueprint: call _link_inherited_grants_to_feature_flag() inside _finalize (after _build_resource_graph populates _root, before _finalize_resources), not before _finalize where it walked an empty graph and never added the grant->flag dependency edge. Strengthen the test to assert the edge itself, not incidental level ordering. - blueprint: surviving_drops() also clears the ACCOUNT_USAGE grant cache, else revoked account-role grants re-appear as false survivors on use_account_usage runs. Test asserts both caches are cleared. - grant: reject ALL/FUTURE '... IN ACCOUNT' at construction (only inherited grants can target the whole account) instead of rendering doubled-ACCOUNT SQL that errors mid-apply; still allow the inherited=True upgrade path. - black-format the merged fetch_warehouse line; rename test var ons->on_values so codespell (now enforced) passes.
Review — no comment produced this roundThe review job finished without producing findings (job log). A maintainer may want to re-run it. |
mypy (unmasked now that the format checks pass) flagged the return of a list[DropResource] where list[ResourceChange] is declared; list invariance. Annotate the accumulator so the subtype appends are accepted.
Review — no comment produced this roundThe review job finished without producing findings (job log). A maintainer may want to re-run it. |
1 similar comment
Review — no comment produced this roundThe review job finished without producing findings (job log). A maintainer may want to re-run it. |
- data_provider: normalize the object type on the inherited-grant fetch path through ResourceType (like the manifest) so synonym types (CORTEX_AGENT_SERVER vs MCP_SERVER) match instead of forcing a non-converging DROP+CREATE that revokes inherited access in sync mode. - blueprint: gate _shared_database_for_grant on on_type so an account-level object (warehouse, integration, ...) named like an imported database revokes its own privilege instead of the share. - var/gitops: reject var.*/parent.* in a for_each 'where' expression (it used to resolve to a literal string and silently filter every item -> empty block -> DROPs in sync mode), and evaluate 'where' inside the per-item try so a bad expression is collected as that item's error instead of aborting the block. - gitops: treat an empty-string singular grant target as absent in _as_list. - enums: delete the unused COLLECTION_GRANT_TYPES constant. Regression tests added for each. Full suite 2018 passed; black/codespell/ruff/mypy clean.
Review — no comment produced this roundThe review job finished without producing findings (job log). A maintainer may want to re-run it. |
… path Confirmed the SHOW FUTURE GRANTS row shape against a live account: name is '<container>.<OBJECT_TYPE>' and the container is inferred from the dot count. The embedded <OBJECT_TYPE> was left raw, so a synonym type (SHOW reports CORTEX_AGENT_SERVER for what GRANT/CREATE call MCP SERVER) read back as <CORTEX_AGENT_SERVER> vs the manifest's <MCP_SERVER>, forcing a non-converging DROP+CREATE that revokes the future grant in sync mode -- the same class as the inherited-grant fix. Normalize the bracketed type through ResourceType at the source (both role and database-role fetchers); no-op for non-synonym/unknown types. Regression test added.
…-identifier containment
- blueprint/cli: persist each change's dependency level in the dumped plan and
restore it on `apply --plan`, so ordering (ownership transfers before creates
inside them, the inherited-grants feature flag before its grants) is preserved.
The apply-plan path previously ran everything at level 0. Older bare-list plan
files fall back to that flat behaviour (backward compatible).
- blueprint: a declared `GRANT ALL` collection grant now covers the concrete-
privilege rows Snowflake fans it into (SELECT, INSERT, ...), instead of only an
exact 'ALL' match -- otherwise sync dropped each expanded row every run.
- blueprint: _container_covers uses a quote-aware split, so a quoted identifier
containing a dot ('"a.b"') is not miscounted and mis-classified.
Regression tests added for each; full suite 2022 passed, lint/mypy/codespell clean.
Review of PR #58Correctness
Minor / worth a look
Nothing else stood out as a likely correctness issue; the bulk of the diff (inherited-grants support, future-grant precedence warnings, |
Review — no comment produced this roundThe review job finished without producing findings (job log). A maintainer may want to re-run it. |
… inline one-use helpers - print_surviving_drops: when a survivor is held by a database role, name the database whose owner role must be granted. The SECURITYADMIN fallback for db-role grantees is deliberate and tested (surviving_drops is the intended surface); this makes that surface actionable instead of changing the routing. - inline NON_INHERITABLE_PRIVS (a one-element set) as a direct OWNERSHIP check, and inline the single-use _grant_grantee_label helper. Full suite 2023 passed; black/codespell/ruff/mypy clean.
Review of PR #58
A
Minor / lower-confidence:
|
- gitops _as_list: also drop empty-string PLURAL-list elements, not just the
singular key (a bad template render of one list item was building a grant to
an empty target).
- blueprint _shared_database_for_grant: quote-aware smart_split, matching the
_container_covers fix, so a database quoted with a literal dot still matches
the share.
- var evaluate_for_each_where: strip string literals before the var./parent.
guard, so a var. inside a quoted value ('see var.docs') no longer wrongly
rejects a valid each.value-only expression.
Regression tests added for each; full suite 2026 passed, lint/mypy/codespell clean.
Review of PR #58snowcap/data_provider.py:1012-1013 —
snowcap/resources/grant.py:449-460 — INHERITED grants never get a snowcap/gitops.py:44-61 — _resources_from_database_role_grants_config([{'database_role': 'somedb.somerole', 'roles': 'analyst'}])produces 7 snowcap/data_provider.py:2333-2336 — snowcap/data_provider.py:2398-2404 — snowcap/data_provider.py:257-263 — snowcap/blueprint.py:224,241,594 — Everything else in the diff (INHERITED grant SQL rendering in |
…redundant @cache - grant string parser: match a collection type's plural form in the multi-word matcher (singularize the candidate), so a 3+-word type like 'CORTEX SEARCH SERVICES' parses as one item instead of splitting and tripping the count guard. - blueprint _format_schema_list: drop the never-overridden limit param. - data_provider fetch_inherited_grants_enabled: drop the @cache decorator (it short-circuited the session-keyed dict, and reset_account_usage_caches -- which clears that dict -- could not clear @cache, so a reset didn't take). Keep the resettable dict. Regression test for the 3-word type; full suite 2027 passed, lint/mypy/codespell clean.
Review — no comment produced this roundThe review job finished without producing findings (job log). A maintainer may want to re-run it. |
Adds support for Snowflake's inherited grants, and fixes eight bugs found while migrating a real config (
datacoves/balboa) onto them against a production account.The bugs are not specific to inherited grants — they are sync-mode grant bugs that the migration happened to walk into. Most of them share one root cause: the config declares a grant one way and Snowflake reports it another, so declared state and remote state never match. In
--sync_resourcesmode any grant a managed role holds that isn't declared becomes a DROP, so a mismatch is not cosmetic: it produces a permanent DROP+CREATE pair that never converges, and in the worst case revokes access that was declared.Inherited grants
GRANT INHERITED <priv> ON ALL <items> IN <container>replaces theALL+FUTUREpair with a single durable record covering current and future objects.feat(grants): support Snowflake inherited grants— the grant form itselffeat(grants): let config enable the inherited grants preview itself—FEATURE_RBAC_INHERITED_GRANTSis settable from the account resource, so a config can turn on the preview it depends onfix(grants): tolerate inherited grants in remote state— inherited grants read back cleanly even in configs that don't declare anyfeat(grants): warn when database-level future grants are shadowed or at risk— schema-level future grants silently shadow database-level ones; this surfaces it rather than letting coverage disappeardocs(grants): correct what Snowcap can and cannot enable— the account parameter is only half of it; the preview also needs account-wide access from SnowflakeGrant identity: declared vs. observed
Identify grants by the object name their DDL uses, not the one SHOW GRANTS reports—SHOW GRANTSsaysCORTEX_AGENT_SERVERfor the object thatGRANTandCREATEcall anMCP SERVER. Same object, two names, so every plan produced a DROP+CREATE for it. Grants are now keyed on the DDL spelling on every emission path.Stop listing database role grants as object grants— a grant of a database role was being read back as aUSAGEgrant on one, so applying it emittedGRANT USAGE ON DATABASE ROLE, which Snowflake rejects outright (391811 Unsupported feature).Do not list the usage a database role is born with— creating a database role grants itUSAGEon its own database. That grant has an emptygranted_by, is timestamped with theCREATE, and cannot be revoked. It was being listed as an undeclared grant, so every plan tried to drop it and every apply silently failed.Read future grants when syncing(part ofa28979c) — remote state for synced types comes only fromlist_*, andlist_grantswasn't asking for future grants. Declared future grants therefore looked absent and were re-created on every run. Also in this commit:IMPORTED PRIVILEGESis reported asUSAGEon share-backed databases, and thefetch_*synonym path is aligned withlist_*.Execution role
Create resources inside a container this plan transfers as its new owner— when a plan transfers ownership of a database and creates objects inside it, the creates were being run as the old owner and failed with003001 Insufficient privileges. The new owner is now used for creates that land inside a container the same plan transfers.Revoke account-level privileges as the system role that owns them—system_role_for_privwas applied toCreateResourcebut notDropResource, so five revokes ran as a role that could not perform them. Snowflake returned SUCCESS for all five and carried out none.Revoke the share when dropping a grant on a shared database— individual privileges on an imported database cannot be revoked (003028); the share grant itself has to go.Surfacing drops Snowflake accepts but doesn't perform
Report drops Snowflake accepted without carrying outadds a post-apply verification pass: after a non-dry-run apply, each destructive change is re-checked against the account, and any grant still present is reported.This is what turned the two silent-revoke bugs above from "the plan keeps showing the same drops and nobody knows why" into a named, actionable warning. It is deliberately a diagnostic rather than a hard failure — some survivors are legitimate (the intrinsic database-role usage above is unrevokable by design).
YAML
Let YAML grant a database role to another database role—to_database_rolewas reachable from the Python API but had no YAML spelling, so composing database roles (the normal way to build a reader/writer hierarchy) required dropping out of config. The loader now accepts singular and plural forms consistently acrossdatabase_role_grants.Other
feat(gitops): add a where filter to for_eachdocs(rbac): document the cloned-database grant patternci: move workflow actions off deprecated Node 20 runtimesstyle: make formatting and spelling checks enforceableTesting
2005 tests passing, up from 1934 on
main. Every fix above carries a regression test written against the shape of the real remote-state row that caused it.Beyond the suite, each fix was verified by running
planandapplyagainst the production Snowflake account the balboa config manages, iterating untilplanreached a clean state — which is how the silent-revoke bugs were found in the first place, since they are invisible to any test that trusts Snowflake's return status.Merge order
This should merge before datacoves/balboa#283, which migrates that config to inherited grants and depends on these fixes to apply cleanly.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DtvxdHFZzsbwDHtr13CTkH
Generated by Claude Code