Skip to content

Inherited grants, plus the sync-mode grant correctness fixes found applying them - #58

Merged
noel merged 35 commits into
mainfrom
fix/grant-sync-correctness
Aug 18, 2026
Merged

Inherited grants, plus the sync-mode grant correctness fixes found applying them#58
noel merged 35 commits into
mainfrom
fix/grant-sync-correctness

Conversation

@noel

@noel noel commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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_resources mode 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 the ALL + FUTURE pair with a single durable record covering current and future objects.

  • feat(grants): support Snowflake inherited grants — the grant form itself
  • feat(grants): let config enable the inherited grants preview itselfFEATURE_RBAC_INHERITED_GRANTS is settable from the account resource, so a config can turn on the preview it depends on
  • fix(grants): tolerate inherited grants in remote state — inherited grants read back cleanly even in configs that don't declare any
  • feat(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 disappear
  • docs(grants): correct what Snowcap can and cannot enable — the account parameter is only half of it; the preview also needs account-wide access from Snowflake

Grant identity: declared vs. observed

  • Identify grants by the object name their DDL uses, not the one SHOW GRANTS reportsSHOW GRANTS says CORTEX_AGENT_SERVER for the object that GRANT and CREATE call an MCP 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 a USAGE grant on one, so applying it emitted GRANT 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 it USAGE on its own database. That grant has an empty granted_by, is timestamped with the CREATE, 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 of a28979c) — remote state for synced types comes only from list_*, and list_grants wasn't asking for future grants. Declared future grants therefore looked absent and were re-created on every run. Also in this commit: IMPORTED PRIVILEGES is reported as USAGE on share-backed databases, and the fetch_* synonym path is aligned with list_*.

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 with 003001 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 themsystem_role_for_priv was applied to CreateResource but not DropResource, 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 out adds 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 roleto_database_role was 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 across database_role_grants.

Other

  • feat(gitops): add a where filter to for_each
  • docs(rbac): document the cloned-database grant pattern
  • ci: move workflow actions off deprecated Node 20 runtimes
  • style: make formatting and spelling checks enforceable

Testing

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 plan and apply against the production Snowflake account the balboa config manages, iterating until plan reached 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

claude added 25 commits August 15, 2026 15:47
…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
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
@github-actions

Copy link
Copy Markdown

Review — no comment produced this round

The review job finished without producing findings (job log). A maintainer may want to re-run it.

noel added 3 commits August 16, 2026 21:00
# 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.
@github-actions

Copy link
Copy Markdown

Review — no comment produced this round

The 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.
@github-actions

Copy link
Copy Markdown

Review — no comment produced this round

The review job finished without producing findings (job log). A maintainer may want to re-run it.

1 similar comment
@github-actions

Copy link
Copy Markdown

Review — no comment produced this round

The 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.
@github-actions

Copy link
Copy Markdown

Review — no comment produced this round

The review job finished without producing findings (job log). A maintainer may want to re-run it.

noel added 2 commits August 17, 2026 13:25
… 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.
@github-actions

Copy link
Copy Markdown

Review of PR #58

Correctness

  1. snowcap/data_provider.py:1012 — The @cache decorator that previously sat directly on fetch_session (confirmed via git show origin/main:snowcap/data_provider.py, where @cache sits at line 758 immediately above def fetch_session) now decorates the newly-inserted fetch_inherited_grants_enabled instead; fetch_session (line 1073) lost its memoization entirely. This has two consequences:

    • fetch_session is called multiple times per logical operation (blueprint.py:1581, 2089, 2233, data_provider.py:743), so a single plan()/apply() now re-issues the session query (CURRENT_ACCOUNT_NAME(), CURRENT_AVAILABLE_ROLES(), etc.) instead of reusing one cached result — a real perf regression and a risk that role/session data drifts mid-run.
    • fetch_inherited_grants_enabled is now double-cached: it keeps its own manual _INHERITED_GRANTS_ENABLED_CACHE dict (cleared by reset_account_usage_caches() at line 1230), but the outer functools.cache wrapper short-circuits the entire function body — including the manual-cache-clear check — for a given session object, so reset_account_usage_caches() can no longer actually invalidate this value. A process that calls plan()/apply() more than once on the same session (e.g. plan → apply → plan) will keep using a stale "inherited grants enabled" verdict.
      Fix: put @cache back on fetch_session, and drop it from fetch_inherited_grants_enabled (which already has its own invalidatable cache).
  2. snowcap/blueprint.py:2469 vs. 2478 (execution_strategy_for_change) — For grant/revoke changes, the system_role_for_priv(grant_data["priv"]) check (line 2469-2473) runs and returns before the new database-role-grantee check (_database_of_database_role_grantee / database_owners, line 2478-2482) is ever reached. If a config grants/revokes an AccountPriv that has a default system-role owner (e.g. MANAGE GRANTS → SECURITYADMIN, CREATE WAREHOUSE/CREATE DATABASE → SYSADMIN) to a DATABASE ROLE grantee, the system role is picked first even though it may lack USAGE on the grantee's database. Per the comment at 2475-2477, that's exactly the case this new code path is meant to prevent: Snowflake reports the REVOKE as successful without applying it, reproducing the "surviving drop" bug this PR otherwise adds surviving_drops() to detect — except here it's produced by this PR's own new ordering. Consider moving the database-role-grantee check before the system_role_for_priv check, or making the two mutually exclusive based on grantee type.

Minor / worth a look

  1. snowcap/blueprint.py:1008_format_grant_name still pluralizes FUTURE/ALL/INHERITED display text with a naive items_type_str + "S", while this same diff introduces inflection.pluralize for the equivalent text in future_grant_precedence_warnings (lines 381, 407) and in resources/grant.py/lifecycle.py for the actual generated SQL. For an irregular plural (e.g. POLICY), the plan diff line would show "POLICYS" while the executed SQL and precedence warnings correctly say "POLICIES" for the same grant — a cosmetic but confusing mismatch between what the plan displays and what runs. Worth swapping in pluralize() here too for consistency.

  2. snowcap/blueprint.py:2532-2545 — The new TransferOwnership branch for OWNER_EXECUTED_RESOURCE_TYPES unconditionally routes through SECURITYADMIN whenever it's in available_roles, with no fallback to the previous owner-based change_owner path if that specific transfer fails (e.g. an account that has deliberately stripped MANAGE GRANTS from SECURITYADMIN). Low risk in the common case, but worth confirming this is the intended tradeoff since it forecloses the previously-working fallback for that resource class.

Nothing else stood out as a likely correctness issue; the bulk of the diff (inherited-grants support, future-grant precedence warnings, surviving_drops detection) reads as a coherent, well-tested fix for the underlying "REVOKE reports success but silently no-ops" class of bug, and follows the existing ResourceSpec/helper-function patterns used elsewhere in blueprint.py.

@github-actions

Copy link
Copy Markdown

Review — no comment produced this round

The 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.
@github-actions

Copy link
Copy Markdown

Review of PR #58

snowcap/data_provider.py:2333_inherited_grant_matches skips the synonym normalization this same PR just added for the identical problem.
This diff introduces _granted_on_label() (data_provider.py:290) specifically because Snowflake's SHOW GRANTS/inherited-grant rows report synonym object types under a different name than the manifest's canonical ResourceType (its own docstring gives the example: CORTEX_AGENT_SERVER vs. the manifest's MCP_SERVER), and the diff also applies the equivalent fix to future grants via _normalize_future_grant_name. But _inherited_grant_matches (used by fetch_inherited_grant) still does a raw grant["granted_on"].replace("_", " ").upper() != items_type... string comparison instead of routing through _granted_on_label/resource_type_for_label. A declared Grant(priv="SELECT", on="INHERITED MCP SERVERS IN DATABASE somedb", to=role) will never match the remote row reported as CORTEX_AGENT_SERVER, so it's reissued on every apply, and surviving_drops (same fetch path) can never confirm a real drop of that type either.

snowcap/var.py:126 — the var./parent. guard regex is bypassable with whitespace, defeating the safety check it was added for.
re.search(r"\b(?:var|parent)\.", condition) requires the dot to immediately follow the identifier, but Jinja's expression grammar tolerates whitespace before an attribute-access dot. Verified directly:

>>> jinja2.Environment().compile_expression('var . x == 1')(var=VarStub())
False

A where: "var . env == 'prod'" slips past the guard, and var.env resolves through VarStub to the literal string "{{ var.env }}", silently returning a wrong-but-truthy/falsy result instead of raising MissingVarException — exactly the "declared grants silently become DROPs in sync mode" failure the guard's own docstring (var.py:121-125) says it exists to prevent.

snowcap/var.py:134evaluate_for_each_where only catches jinja2.exceptions.UndefinedError; other evaluation errors escape both this function and its caller's error collection.
gitops.py's per-item try/except around this call (gitops.py:277) only catches (InvalidKeyException, ValueError, MissingVarException), and the outer per-resource try/except (gitops.py:290) catches the same set. A where expression like each.value.count > 'text' that raises a native TypeError (e.g. comparing an int to a str when for_each items are inconsistently typed) is caught by neither layer, so it aborts the entire config load instead of being collected as that one item's validation error, contradicting the inline comment at gitops.py:249-251 ("Inside the try so a bad where ... is collected as this item's validation error instead of aborting the entire for_each block").

snowcap/blueprint.py:2402 and :2469 — two new helpers parse dotted identifiers with a plain str.split(".")[0] instead of the quote-aware smart_split this same PR introduces for the identical problem.
_container_covers (blueprint.py:411-421, new in this diff) explicitly switches to smart_split(object_name, ".") with the comment "a plain str.split would miscount and mis-classify" a quoted identifier containing a literal dot. But the sibling functions added in the same diff — _shared_database_for_grant (database = str(on).split(".")[0].strip('"').upper(), line 2402) and _database_of_database_role_grantee (grantee.split(".")[0].strip('"').upper(), line 2469) — still use the naive split. A shared database or database-role name containing a literal dot inside quotes (e.g. "my.shared.db") is mis-split to "my, so _shared_database_for_grant fails to route the revoke through drop_shared_database_grant, and _database_of_database_role_grantee fails to route grant execution to the owning database's role.

snowcap/blueprint.py:2508-2513 — the new database-role-grantee execution-role routing only fires for Create/Drop grant changes, not Update.
_database_of_database_role_grantee (called at blueprint.py:2509) returns None unless isinstance(change, (CreateResource, DropResource)) (see its own branching at blueprint.py:2458-2463), so for an UpdateResource grant change it always returns None and the call falls through to the generic SECURITYADMIN fallback a few lines down. grant_option is not part of the grant's FQN (grant_fqn, resources/grant.py:561-579, keys only on grant_type/priv/on/to), so toggling grant_option on an existing grant produces an UpdateResource, not a Create+Drop pair. For a grant to a database role, SECURITYADMIN can hold MANAGE GRANTS yet still be unable to resolve the grantee without USAGE on its database — which is exactly the silent-no-op class of bug (REVOKE/GRANT reports success without effect) this PR's _database_of_database_role_grantee was added to fix for Create/Drop, just not for Update.

snowcap/data_provider.py:1012-1013fetch_inherited_grants_enabled is both @cached and manually cached; reset_account_usage_caches() only clears the manual cache.
The function is decorated with @cache (functools) and keeps its own _INHERITED_GRANTS_ENABLED_CACHE dict keyed by id(session). reset_account_usage_caches() (data_provider.py:1214-1230) clears the manual dict but never calls fetch_inherited_grants_enabled.cache_clear(). If a session object is reused across two plan()/apply calls in the same process (e.g. a long-lived test harness or service), the second call always returns the first call's stale result via the outer functools.cache, even after an explicit cache reset — so raise_if_inherited_grants_unavailable can keep blocking (or allowing) applies based on stale account state.

Minor / lower-confidence:

  • snowcap/gitops.py:59_as_list does values.extend(config.get(plural) or []) with no type check. A YAML typo like roles: bob (string instead of a list) silently iterates the string into three single-character grant targets ('b', 'o', 'b') rather than raising a clear config error.
  • snowcap/blueprint.py:224-260manifest_has_future_grants, manifest_future_grant_roles, and manifest_future_grant_database_roles appear to be dead code now: their only call site was removed when the sync-resources path switched to unconditionally passing include_future_grants=True. No remaining references in snowcap/ or tests/. Worth deleting or confirming there's a planned caller.

- 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.
@github-actions

Copy link
Copy Markdown

Review of PR #58

snowcap/data_provider.py:1012-1013 — @cache landed on the wrong function, silently un-caching fetch_session and permanently caching fetch_inherited_grants_enabled.
On main (git show origin/main:snowcap/data_provider.py:758), @cache decorated fetch_session directly. This PR inserted fetch_inherited_grants_enabled/fetch_preview_access_enabled between the decorator and fetch_session, so @cache now applies to fetch_inherited_grants_enabled (data_provider.py:1013) and fetch_session (data_provider.py:1073) is left uncached.

  • tests/integration/data_provider/test_list_resource.py:102 calls data_provider.fetch_session.cache_clear() as its first statement — this now raises AttributeError since fetch_session is a plain function.
  • fetch_inherited_grants_enabled gets an unbounded functools.cache on top of its own manual _INHERITED_GRANTS_ENABLED_CACHE dict, which is cleared by reset_account_usage_caches() (data_provider.py:1230). The outer functools.cache has no .cache_clear() caller anywhere, so a reused session keeps a stale enabled/disabled answer forever, even across an explicit cache reset.

snowcap/resources/grant.py:449-460 — INHERITED grants never get a requires() dependency edge on their container, unlike FUTURE grants.
granted_in_ref is only set if grant_type == GrantType.FUTURE: (lines 449-450, 459-460); self.requires(granted_in_ref) (line 500) is the only thing that would order the grant after its database/schema. A manifest that declares a new database/schema together with on: INHERITED TABLES IN DATABASE ... on it in the same apply has no dependency edge forcing the grant after the CREATE DATABASE/CREATE SCHEMA, so compute_levels can place the GRANT INHERITED ... at the same or an earlier level, and the apply can fail because the container doesn't exist yet. The new feature-flag wiring (_link_inherited_grants_to_feature_flag, blueprint.py) links the grant to the RBAC feature flag but not to the container itself, and tests/test_blueprint.py::TestInheritedGrantPlanning::test_inherited_grants_are_applied_after_the_feature_flag doesn't assert level ordering against the database, so this path is untested.

snowcap/gitops.py:44-61 — _as_list silently explodes a mistakenly-scalar plural key into per-character grant targets instead of erroring.
values.extend(v for v in (config.get(plural) or []) if v not in (None, "")) iterates whatever config.get(plural) returns. If a user writes roles: analyst (forgetting the list dash) instead of roles: [analyst], config.get("roles") is the string "analyst", which is iterable, so this yields one grant per character. Reproduced directly:

_resources_from_database_role_grants_config([{'database_role': 'somedb.somerole', 'roles': 'analyst'}])

produces 7 DatabaseRoleGrants to roles A, N, A, L, Y, S, T with no exception. This is new in this PR (_as_list is a new helper, used both by the pre-existing roles/to_role path and the new database_roles/to_database_role path at gitops.py:191-195) — a bad YAML indent now silently plans nonsense single-letter-role grants instead of failing.

snowcap/data_provider.py:2333-2336 — _inherited_grant_matches skips the synonym normalization applied everywhere else in this same PR, so fetch_inherited_grant disagrees with list_grants for synonym object types.
_granted_on_label (data_provider.py:290-312) was added in this PR specifically because Snowflake reports some object types under a different name than their DDL (e.g. CORTEX_AGENT_SERVER for what the manifest calls MCP_SERVER), and is used consistently in _fetch_grant_to_role and list_grants. _inherited_grant_matches, used by fetch_inherited_grant, instead does a raw grant["granted_on"].replace("_", " ").upper() != items_type.replace("_", " ").upper() with no ResourceType/synonym resolution. A declared INHERITED grant on mcp_server whose underlying Snowflake row reports granted_on="CORTEX_AGENT_SERVER" never matches here, so fetch_inherited_grant reports the grant as missing even though list_grants (which does normalize) reports it as present — plan/apply can treat an existing inherited grant as absent.

snowcap/data_provider.py:2398-2404 — fetch_grant's priv == "ALL" branch wasn't updated with the same synonym fix applied to its sibling branch.
The else branch just below (line ~2416, _fetch_grant_to_role) and list_grants both compare object types via _granted_on_label(...). The ALL branch still builds filters = {"granted_on": on_type} and passes it straight to _filter_result with no normalization. GRANT ALL ON MCP_SERVER ... TO ROLE r (on_type="MCP_SERVER") never matches a SHOW GRANTS row reporting granted_on="CORTEX_AGENT_SERVER", so fetch_grant returns nothing and plan/apply proposes (re-)creating the ALL grant on every run.

snowcap/data_provider.py:257-263 — _is_intrinsic_database_role_usage compares plain strings instead of using ResourceName, unlike the quote-aware fix this same PR applies elsewhere (e.g. _fetch_grant_to_role's "Use ResourceName for comparison to handle quoted identifiers correctly").
return str(row["name"]).upper() == grantee.split(".")[0].upper(). The caller builds fq_db_role_name = f"{db_role_fqn.database}.{db_role_fqn.name}" where db_role_fqn.database is a ResourceName; str() on a quoted ResourceName embeds literal double quotes (resource_name.py:61-62). For a database created as CREATE DATABASE "analytics", fq_db_role_name becomes '"analytics".MY_ROLE', so grantee.split(".")[0].upper() is '"ANALYTICS"' (quotes included) while SHOW GRANTS reports the bare name "analytics""ANALYTICS". The comparison never matches, so the intrinsic un-revokable USAGE grant this function exists to filter out (per its own docstring) leaks into remote state, and sync proposes dropping it every run, forever, for any quoted/case-sensitive database name.

snowcap/blueprint.py:224,241,594 — manifest_has_future_grants, manifest_future_grant_roles, and manifest_future_grant_database_roles are now dead code.
The diff changed Blueprint.plan's only call site to unconditionally set list_kwargs['include_future_grants'] = True instead of using these helpers to narrow the query. No remaining callers exist anywhere in snowcap/ or tests/ — worth deleting rather than leaving unused.

Everything else in the diff (INHERITED grant SQL rendering in lifecycle.py, the plan-levels plumbing in operations/blueprint.py/cli.py, var.py's evaluate_for_each_where, and the ResourceType synonym table in enums.py) checked out correctly against direct testing.

…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.
@github-actions

Copy link
Copy Markdown

Review — no comment produced this round

The review job finished without producing findings (job log). A maintainer may want to re-run it.

@noel
noel merged commit 1f37ddf into main Aug 18, 2026
6 checks passed
@noel
noel deleted the fix/grant-sync-correctness branch August 18, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants