Skip to content

Commit 1dced6e

Browse files
Bugfix/agent incorrect revoke (#65)
* Change tests to show problems. * Revised test. * Fix agent bug * Simplify logic. * Rename objectType field to sqlQueryObjectType to be more descriptive. * Removed account level test * Reverted rename to objectType and objectTypePlural to avoid merge conflicts
1 parent f4d6664 commit 1dced6e

11 files changed

Lines changed: 125 additions & 77 deletions

File tree

src/main/java/us/zoom/data/dfence/providers/snowflake/SnowflakeWildcardAllGrantFilter.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ public static List<SnowflakeGrantBuilder> containerAllGrantBuilders(
6060
playbookPrivilegeGrant.databaseName(),
6161
playbookPrivilegeGrant.schemaName()));
6262
}
63-
String objectName = String.format("%s.<%s>", containerName, objectType.getObjectType().replace(" ", "_"));
63+
String objectName = String.format("%s.<%s>", containerName, objectType.name());
6464
List<SnowflakeGrantModel> grants = playbookPrivilegeGrant.privileges().stream()
6565
.map(p -> new SnowflakeGrantModel(
6666
p,
67-
objectType.getObjectType().replace(" ", "_"),
67+
objectType.name(),
6868
objectName,
6969
"ROLE",
7070
roleName,

src/main/java/us/zoom/data/dfence/providers/snowflake/grant/builder/SnowflakeObjectType.java

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@
55
import java.util.Map;
66
import java.util.Objects;
77

8+
/**
9+
* Snowflake object types that can be granted on (databases, schemas, tables, agents, etc.).
10+
* <p>
11+
* Each type has a qualification level ({@link #getQualLevel()}) indicating how many name parts
12+
* it has: 0 = account-level, 1 = one part (e.g. database name), 2 = two parts (e.g. db.schema),
13+
* 3 = three parts (e.g. db.schema.object). The {@link #getObjectType()} and {@link
14+
* #getObjectTypePlural()} strings are used in SQL (e.g. "SHOW AGENTS IN DATABASE"). Use {@link
15+
* #name()} when building grant names so desired state matches what Snowflake
16+
* returns for the object type in SHOW GRANTS statements. {@link #name()} is also used for matching the object name as
17+
* it appears in future grant object names such as MY_DB.MY_SCHEMA.&ltTABLE&gt..
18+
* <p>
19+
* {@link #getAliasFor()} is used only when building hash keys for grants (e.g. in {@code
20+
* SnowflakeGrantBuilder.getKey()} and the revoke index). It is not user-facing and does not
21+
* allow the user to specify a different name in the playbook. It exists to align on differences
22+
* that come from Snowflake: the same logical object type may appear under different names in
23+
* different Snowflake APIs (e.g. MATERIALIZED_VIEW in one place, VIEW in another). By having
24+
* multiple enum values return the same alias, they are treated as the same when calculating
25+
* which grants to add or remove. For playbook input names (e.g. "AGENT" instead of
26+
* "CORTEX_AGENT"), see {@link #overrideObjectTypes}.
27+
*
28+
* @see #fromString(String) for resolving playbook or Snowflake strings to this enum
29+
* @see #overrideObjectTypes for playbook input mapping (e.g. AGENT → CORTEX_AGENT)
30+
*/
831
public enum SnowflakeObjectType {
932
ACCOUNT(0, null),
1033
ALERT(3, null),
@@ -51,30 +74,49 @@ public enum SnowflakeObjectType {
5174
IMAGE_REPOSITORY(3, null);
5275

5376

77+
/** Number of name parts (0=account, 1=e.g. db, 2=e.g. db.schema, 3=e.g. db.schema.object). */
5478
@Getter
5579
private final Integer qualLevel;
5680

57-
// objectType is used for SQL statements about the object.
81+
/** Singular form used in SQL (e.g. "TABLE" will be used in "GRANT SELECT ON TABLE TO ROLE FOO"). */
5882
@Getter
5983
private final String objectType;
6084

61-
// objectTypePlural is used for SQL statements about multiple objects.
85+
/** Plural form used in SQL (e.g. TABLES WILL BE USED IN "SHOW GRANTS ON TABLES"). */
6286
@Getter
6387
private final String objectTypePlural;
6488

65-
// Alias for is used for considering grants on different object types equivalent for the sake of hashing and comparison.
89+
/**
90+
* When non-null, used only in hash keys so that this type and another (e.g. VIEW) are
91+
* treated as the same when computing grants to add or remove. Aligns Snowflake variations
92+
* (e.g. MATERIALIZED_VIEW may appear as VIEW in some output). Not user-facing; see {@link
93+
* #overrideObjectTypes} for playbook input names. However, aliasFor does not change the
94+
* underlying enum type. It is only changes how the objectType is used for calculating
95+
* grants to add or remove.
96+
*/
97+
@Getter
6698
private final String aliasFor;
6799

68-
// Main constructor with all parameters - contains the actual logic
69-
SnowflakeObjectType(Integer qualLevel, String aliasFor, String objectType, String objectTypePlural) {
100+
/**
101+
* Main constructor with all parameters.
102+
*
103+
* @param qualLevel number of name parts (0=account, 1=e.g. db, 2=e.g. db.schema,
104+
* 3=e.g. db.schema.object)
105+
* @param aliasFor when non-null, value used in hash keys so this type matches another
106+
* (e.g. MATERIALIZED_VIEW uses "VIEW"); null if no alias
107+
* @param objectType singular form for SQL (e.g. "AGENT", "TABLE"); null to infer from
108+
* enum name (underscores to spaces)
109+
* @param sqlQueryObjectTypePlural plural form for SQL (e.g. "AGENTS", "TABLES"); null to infer from
110+
* objectType
111+
*/
112+
SnowflakeObjectType(Integer qualLevel, String aliasFor, String objectType, String sqlQueryObjectTypePlural) {
70113
this.qualLevel = qualLevel;
71-
this.aliasFor = aliasFor;
72114
// If objectType is provided, use it; otherwise infer from enum name
73115
String computedObjectType = (objectType != null) ? objectType : this.name().replace("_", " ");
74116
this.objectType = computedObjectType;
75117
// If objectTypePlural is provided, use it; otherwise infer from objectType
76-
if (objectTypePlural != null) {
77-
this.objectTypePlural = objectTypePlural;
118+
if (sqlQueryObjectTypePlural != null) {
119+
this.objectTypePlural = sqlQueryObjectTypePlural;
78120
} else {
79121
// Hooked on phonics works for me.
80122
if (computedObjectType.endsWith("Y")) {
@@ -83,26 +125,43 @@ public enum SnowflakeObjectType {
83125
this.objectTypePlural = computedObjectType + "S";
84126
}
85127
}
128+
this.aliasFor = Objects.requireNonNullElseGet(aliasFor, this::name);
86129
}
87130

88-
// Constructor that infers objectType and objectTypePlural from enum name
131+
/**
132+
* Constructor that infers objectType and objectTypePlural from enum name (e.g. TABLE → "TABLE",
133+
* "TABLES").
134+
*
135+
* @param qualLevel number of name parts (0=account, 1=e.g. db, 2=e.g. db.schema,
136+
* 3=e.g. db.schema.object)
137+
* @param aliasFor when non-null, value used in hash keys so this type matches another; null
138+
* if no alias
139+
*/
89140
SnowflakeObjectType(Integer qualLevel, String aliasFor) {
90141
this(qualLevel, aliasFor, null, null);
91142
}
92143

93-
public String getAliasFor() {
94-
if (this.aliasFor == null) {
95-
return this.name();
96-
}
97-
return this.aliasFor;
98-
}
99-
144+
/**
145+
* Resolves a string (from playbook, Snowflake grant output, etc.) to this enum.
146+
* Applies {@link #overrideObjectTypes} so that e.g. "AGENT" maps to {@link #CORTEX_AGENT}.
147+
*
148+
* @param objectType the object type string (case-insensitive)
149+
* @return the corresponding SnowflakeObjectType
150+
* @throws IllegalArgumentException if the string does not match any enum and is not in the
151+
* override map
152+
*/
100153
public static SnowflakeObjectType fromString(String objectType) {
101154
String normalizedObjectType = objectType.toUpperCase();
102155
String overrideValue = overrideObjectTypes.get(normalizedObjectType);
103156
return SnowflakeObjectType.valueOf(Objects.requireNonNullElse(overrideValue, normalizedObjectType));
104157
}
105158

159+
/**
160+
* Map of playbook (or Snowflake) input names to enum names. Used by {@link #fromString(String)}
161+
* so that e.g. "AGENT" in a playbook resolves to {@link #CORTEX_AGENT}. This is what allows
162+
* the user to specify a different name in the playbook; it is separate from {@link
163+
* #getAliasFor()}, which is only for hash keys.
164+
*/
106165
public static Map<String, String> overrideObjectTypes = Map.of(
107166
"AGENT", "CORTEX_AGENT"
108167
);

src/main/java/us/zoom/data/dfence/providers/snowflake/grant/builder/SnowflakePermissionGrantBuilder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public class SnowflakePermissionGrantBuilder extends SnowflakeGrantBuilder {
148148
List.of("MODIFY", "MONITOR", "OPERATE", "USAGE"),
149149
List.of(SnowflakeObjectType.COMPUTE_POOL)));
150150
add(new GrantValidationDefinition(List.of("READ", "WRITE"), List.of(SnowflakeObjectType.IMAGE_REPOSITORY)));
151-
add(new GrantValidationDefinition(List.of("SELECT", "REFERENCES"), List.of(SnowflakeObjectType.SEMANTIC_VIEW)));
151+
add(new GrantValidationDefinition(List.of("SELECT", "REFERENCES", "MONITOR"), List.of(SnowflakeObjectType.SEMANTIC_VIEW)));
152152
add(new GrantValidationDefinition(List.of("USAGE", "MODIFY", "MONITOR"), List.of(SnowflakeObjectType.CORTEX_AGENT)));
153153
}});
154154

src/main/java/us/zoom/data/dfence/providers/snowflake/grant/desired/create/internal/AllGrantsCompiler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ private List<SnowflakeGrantModel> expandAllGrants(
5050
results.add(
5151
new SnowflakeGrantModel(
5252
privilege,
53-
objectType.getObjectType().replace(" ", "_"),
53+
objectType.name(),
5454
objectName,
5555
"ROLE",
5656
roleName,

src/main/java/us/zoom/data/dfence/providers/snowflake/grant/desired/create/internal/FutureGrantsCompiler.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,13 @@ private List<SnowflakeGrantModel> createFutureGrantsOnContainer(
6767
List<String> privileges,
6868
String roleName,
6969
Boolean grantOption) {
70-
String objectName =
71-
String.format(
72-
"%s.<%s>", containerName, objectType.getObjectType().replace(" ", "_").toUpperCase());
70+
String objectName = String.format("%s.<%s>", containerName, objectType.name());
7371
return privileges.stream()
7472
.map(
7573
p ->
7674
new SnowflakeGrantModel(
7775
p,
78-
objectType.getObjectType().replace(" ", "_"),
76+
objectType.name(),
7977
objectName,
8078
"ROLE",
8179
roleName,

src/test/java/us/zoom/data/dfence/providers/snowflake/grant/builder/SnowflakeGrantBuilderTest.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@
1313
import java.util.List;
1414
import java.util.stream.Stream;
1515

16-
import static org.junit.jupiter.api.Assertions.assertEquals;
17-
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
18-
import static org.junit.jupiter.api.Assertions.assertNull;
19-
import static org.junit.jupiter.api.Assertions.assertThrows;
16+
import static org.junit.jupiter.api.Assertions.*;
2017

2118
/**
2219
* Tests for SnowflakeGrantBuilder.
@@ -142,6 +139,22 @@ void fromGrant(GrantTestDataLoader.FixtureGrantTestData testData) throws RbacDat
142139
"Expected builder class mismatch for: " + testData.name());
143140
}
144141

142+
@Test
143+
void agentGrantBuilderFromSnowflakeGrantModel() {
144+
SnowflakeGrantModel grantModel = new SnowflakeGrantModel(
145+
"USAGE",
146+
"AGENT",
147+
"MOCK_DB.MOCK_SCHEMA.<CORTEX_AGENT>",
148+
"ROLE",
149+
"MOCK_ROLE",
150+
false,
151+
true,
152+
false
153+
);
154+
SnowflakeGrantBuilder builder = SnowflakeGrantBuilder.fromGrant(grantModel);
155+
assertNotNull(builder);
156+
}
157+
145158
/**
146159
* Tests conversion from SnowflakeGrantModel to PlaybookPrivilegeGrant.
147160
* Test cases are defined in playbook-privilege-grants.yml

src/test/java/us/zoom/data/dfence/providers/snowflake/revoke/SnowflakeRevokeGrantsCompilerTest.java

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -366,38 +366,6 @@ void compileRevokeGrants_whenInvalidObjectTypeInPlaybook_shouldThrow() {
366366
List.of(playbookGrant), currentGrants));
367367
}
368368

369-
@Test
370-
void compileRevokeGrants_whenInvalidObjectTypeInGrant_cannotCreateBuilder() {
371-
// Given
372-
PlaybookPrivilegeGrant playbookGrant =
373-
createPlaybookGrant("TABLE", "TEST_DB", "TEST_SCHEMA", "TEST_TABLE", List.of("SELECT"));
374-
SnowflakeGrantModel invalidGrant =
375-
new SnowflakeGrantModel(
376-
"SELECT",
377-
"INVALID_TYPE",
378-
"TEST_DB.TEST_SCHEMA.TEST_TABLE",
379-
"ROLE",
380-
"TEST_ROLE",
381-
false,
382-
false,
383-
false);
384-
SnowflakeGrantModel validGrant =
385-
createGrant("UPDATE", "TABLE", "TEST_DB.TEST_SCHEMA.TEST_TABLE", "ROLE", "TEST_ROLE");
386-
Map<String, SnowflakeGrantBuilder> currentGrants =
387-
createCurrentGrants(invalidGrant, validGrant);
388-
389-
// When
390-
List<SnowflakeGrantBuilder> actualRevokes =
391-
SnowflakeRevokeGrantsCompiler.compileRevokeGrants(List.of(playbookGrant), currentGrants);
392-
393-
// Then
394-
assertEquals(1, currentGrants.values().size());
395-
assertEquals(1, actualRevokes.size());
396-
SnowflakeGrantModel revokedGrant = actualRevokes.get(0).getGrant();
397-
assertEquals("UPDATE", revokedGrant.privilege());
398-
assertEquals("TABLE", revokedGrant.grantedOn());
399-
}
400-
401369
@Test
402370
void compileRevokeGrants_whenDatabaseAndSchemaWildcards_shouldNotRevoke() {
403371
// Given
@@ -656,14 +624,10 @@ private SnowflakeGrantModel createAllGrant(
656624
private Map<String, SnowflakeGrantBuilder> createCurrentGrants(SnowflakeGrantModel... grants) {
657625
Map<String, SnowflakeGrantBuilder> currentGrants = new HashMap<>();
658626
for (SnowflakeGrantModel grant : grants) {
659-
try {
660627
SnowflakeGrantBuilder builder = SnowflakeGrantBuilder.fromGrant(grant);
661628
if (builder != null) {
662629
currentGrants.put(builder.getKey(), builder);
663630
}
664-
} catch (Exception e) {
665-
// Skip grants that cannot be built
666-
}
667631
}
668632
return currentGrants;
669633
}
@@ -813,6 +777,23 @@ void compileRevokeGrants_whenPlaybookHasAgentWithWildcard_shouldNotRevoke() {
813777
"Should not revoke when playbook has AGENT with wildcard object name");
814778
}
815779

780+
@Test
781+
void compileRevokeGrantsFuture_whenPlaybookHasAgentWithWildcard_shouldNotRevoke() {
782+
// Critical: Wildcard object name should match any agent name
783+
PlaybookPrivilegeGrant playbookGrant =
784+
createPlaybookGrant("AGENT", "TEST_DB", "TEST_SCHEMA", "*", List.of("USAGE"));
785+
SnowflakeGrantModel currentGrant =
786+
new SnowflakeGrantModel("USAGE", "CORTEX_AGENT", "TEST_DB.TEST_SCHEMA.<CORTEX_AGENT>", "ROLE", "TEST_ROLE", false, true, false);
787+
Map<String, SnowflakeGrantBuilder> currentGrants = createCurrentGrants(currentGrant);
788+
789+
List<SnowflakeGrantBuilder> actualRevokes =
790+
SnowflakeRevokeGrantsCompiler.compileRevokeGrants(List.of(playbookGrant), currentGrants);
791+
792+
assertTrue(
793+
actualRevokes.isEmpty(),
794+
"Should not revoke when playbook has AGENT with wildcard object name");
795+
}
796+
816797
@Test
817798
void compileRevokeGrants_whenPlaybookHasAgentCaseInsensitive_shouldNotRevoke() {
818799
// Critical: Case should not matter for AGENT override

src/test/java/us/zoom/data/systemtest/TestRevokeGrantsSystemTest.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ public void testApplyInitialGrants() throws SQLException {
127127

128128
// Ensure that there are no more changes.
129129
ChangesSummary newChanges = playbookService.compileChanges(false);
130-
Assert.assertTrue(newChanges.changes().isEmpty(), "Expected no remaining changes after apply");
130+
Assert.assertTrue(newChanges.changes().isEmpty(), String.format("Expected no remaining changes after apply. Changes: %s", newChanges.changes()));
131131

132132
// Validate that the role has the expected grants from playbook.
133133
List<Grant> grantsExpected = new ArrayList<>(List.of(
@@ -145,10 +145,6 @@ public void testCreateExtraGrants() throws SQLException {
145145
// Create extra grants NOT covered by the playbook
146146
try (Connection connection = securityadminSnowflakeConnectionProvider.getConnection()) {
147147
// Grant MONITOR on database (not in playbook)
148-
connection.createStatement().execute(
149-
String.format("GRANT MONITOR ON DATABASE %s TO ROLE %s",
150-
fixture.databaseName(), fixture.roleName())
151-
);
152148
// Grant USAGE on schema (not in playbook)
153149
connection.createStatement().execute(
154150
String.format("GRANT USAGE ON SCHEMA \"%s\".\"%s\" TO ROLE %s",

src/test/resources/test-data/grant-builder/grant-revoke-statements.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,15 @@ tests:
169169
expectedGrantStatement: "GRANT SELECT ON SEMANTIC VIEW \"MOCK_DB\".\"MOCK_SCHEMA\".\"MOCK_VIEW\" TO ROLE MOCK_ROLE_1;"
170170
expectedRevokeStatement: "REVOKE SELECT ON SEMANTIC VIEW \"MOCK_DB\".\"MOCK_SCHEMA\".\"MOCK_VIEW\" FROM ROLE MOCK_ROLE_1;"
171171

172+
- name: "MONITOR on SEMANTIC_VIEW"
173+
grant:
174+
privilege: MONITOR
175+
objectType: SEMANTIC_VIEW
176+
objectName: MOCK_DB.MOCK_SCHEMA.MOCK_VIEW
177+
role: MOCK_ROLE_1
178+
expectedGrantStatement: "GRANT MONITOR ON SEMANTIC VIEW \"MOCK_DB\".\"MOCK_SCHEMA\".\"MOCK_VIEW\" TO ROLE MOCK_ROLE_1;"
179+
expectedRevokeStatement: "REVOKE MONITOR ON SEMANTIC VIEW \"MOCK_DB\".\"MOCK_SCHEMA\".\"MOCK_VIEW\" FROM ROLE MOCK_ROLE_1;"
180+
172181
- name: "REFERENCES on SEMANTIC_VIEW"
173182
grant:
174183
privilege: REFERENCES

src/test/resources/test-data/system-test/create-grants/roles.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ roles:
3939
full_patterns_role:
4040
name: ${var.role2}
4141
grants:
42-
# Qual 0: account (global object)
43-
- object-type: account
44-
privileges:
45-
- monitor
4642
# Qual 1: normal/account-level role, database
4743
- object-type: role
4844
object-name: ${var.role}

0 commit comments

Comments
 (0)