Skip to main content

mz_deploy/cli/commands/
grants.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Shared helpers for grant reconciliation across apply commands.
11
12use crate::cli::CliError;
13use crate::cli::executor::DeploymentExecutor;
14use crate::client::{Client, ObjectGrant};
15use crate::info;
16use crate::project::ir::object_id::ObjectId;
17use mz_sql_parser::ast::{
18    GrantPrivilegesStatement, GrantTargetSpecification, GrantTargetSpecificationInner, Ident,
19    ObjectType, Privilege, PrivilegeSpecification, Raw, RevokePrivilegesStatement,
20    UnresolvedItemName, UnresolvedObjectName,
21};
22use owo_colors::{OwoColorize, Stream, Style};
23use std::collections::BTreeSet;
24use std::fmt;
25
26/// The kind of database object for grant reconciliation.
27///
28/// Groups the catalog table name, SQL keyword, privilege set, and display label
29/// that vary per object type so callers don't have to pass four loose strings.
30#[derive(Clone, Copy)]
31pub enum GrantObjectKind {
32    Table,
33    Source,
34    Secret,
35    Connection,
36}
37
38impl GrantObjectKind {
39    pub fn catalog_table(&self) -> &'static str {
40        match self {
41            Self::Table => "mz_tables",
42            Self::Source => "mz_sources",
43            Self::Secret => "mz_secrets",
44            Self::Connection => "mz_connections",
45        }
46    }
47
48    pub fn grant_target(&self, obj_id: &ObjectId) -> GrantTargetSpecification<Raw> {
49        let object_type = match self {
50            Self::Table | Self::Source => ObjectType::Table,
51            Self::Secret => ObjectType::Secret,
52            Self::Connection => ObjectType::Connection,
53        };
54        let item_name = UnresolvedItemName::qualified(&[
55            Ident::new_unchecked(obj_id.expect_database()),
56            Ident::new_unchecked(obj_id.schema()),
57            Ident::new_unchecked(obj_id.object()),
58        ]);
59        build_grant_target(object_type, UnresolvedObjectName::Item(item_name))
60    }
61
62    pub fn all_privileges(&self) -> &'static [&'static str] {
63        match self {
64            Self::Table => &["SELECT", "INSERT", "UPDATE", "DELETE"],
65            Self::Source => &["SELECT"],
66            Self::Secret | Self::Connection => &["USAGE"],
67        }
68    }
69
70    pub fn label(&self) -> &'static str {
71        match self {
72            Self::Table => "table",
73            Self::Source => "source",
74            Self::Secret => "secret",
75            Self::Connection => "connection",
76        }
77    }
78
79    /// The `object_type` string used in `mz_default_privileges`.
80    pub fn object_type_str(&self) -> &'static str {
81        match self {
82            Self::Table | Self::Source => "table",
83            Self::Secret => "secret",
84            Self::Connection => "connection",
85        }
86    }
87}
88
89/// Build a [`GrantTargetSpecification`] for a single named object.
90fn build_grant_target(
91    object_type: ObjectType,
92    name: UnresolvedObjectName,
93) -> GrantTargetSpecification<Raw> {
94    GrantTargetSpecification::Object {
95        object_type,
96        object_spec_inner: GrantTargetSpecificationInner::Objects { names: vec![name] },
97    }
98}
99
100/// The kind of named infrastructure object for grant reconciliation.
101///
102/// Named objects (clusters, network policies) use simpler catalog lookups
103/// than schema-qualified database objects.
104pub enum GrantNamedObjectKind {
105    Cluster,
106    NetworkPolicy,
107}
108
109impl GrantNamedObjectKind {
110    fn grant_target(&self, name: &str) -> GrantTargetSpecification<Raw> {
111        let (object_type, object_name) = match self {
112            Self::Cluster => (
113                ObjectType::Cluster,
114                UnresolvedObjectName::Cluster(Ident::new_unchecked(name)),
115            ),
116            Self::NetworkPolicy => (
117                ObjectType::NetworkPolicy,
118                UnresolvedObjectName::NetworkPolicy(Ident::new_unchecked(name)),
119            ),
120        };
121        build_grant_target(object_type, object_name)
122    }
123
124    fn all_privileges(&self) -> &'static [&'static str] {
125        match self {
126            Self::Cluster => &["USAGE", "CREATE"],
127            Self::NetworkPolicy => &["USAGE"],
128        }
129    }
130
131    fn label(&self) -> &'static str {
132        match self {
133            Self::Cluster => "cluster",
134            Self::NetworkPolicy => "network policy",
135        }
136    }
137}
138
139/// Reconcile grants for a named infrastructure object (cluster or network policy).
140///
141/// Three-step algorithm:
142/// 1. Apply all desired GRANTs idempotently (GRANT is a no-op if already present).
143/// 2. Query the live grant state and default-privilege grants from the catalog.
144/// 3. Compute the set difference (current - desired - protected) and REVOKE stale grants.
145pub async fn reconcile_named_object(
146    client: &Client,
147    executor: &DeploymentExecutor<'_>,
148    name: &str,
149    grants: &[GrantPrivilegesStatement<Raw>],
150    kind: &GrantNamedObjectKind,
151) -> Result<(), CliError> {
152    for grant in grants {
153        executor.execute_sql(grant).await?;
154    }
155    let introspection = client.introspection();
156    let (current, default_privs) = match kind {
157        GrantNamedObjectKind::Cluster => (
158            introspection
159                .get_cluster_grants(name)
160                .await
161                .map_err(CliError::Connection)?,
162            introspection
163                .get_default_privilege_grants_for_cluster(name)
164                .await
165                .map_err(CliError::Connection)?,
166        ),
167        GrantNamedObjectKind::NetworkPolicy => (
168            introspection
169                .get_network_policy_grants(name)
170                .await
171                .map_err(CliError::Connection)?,
172            introspection
173                .get_default_privilege_grants_for_network_policy(name)
174                .await
175                .map_err(CliError::Connection)?,
176        ),
177    };
178    let protected: BTreeSet<_> = default_privs
179        .iter()
180        .map(|g| (g.grantee.to_lowercase(), g.privilege_type.to_uppercase()))
181        .collect();
182    let desired = desired_grants(grants, kind.all_privileges());
183    let target = kind.grant_target(name);
184    let revocations = stale_grant_revocations(&current, &desired, &protected, &target);
185    execute_revocations(executor, &revocations, kind.label(), &name).await
186}
187
188/// Reconcile grants for a single object: apply desired grants, revoke stale ones.
189///
190/// Three-step algorithm:
191/// 1. Apply all desired GRANTs idempotently (GRANT is a no-op if already present).
192/// 2. Query the live grant state and default-privilege grants from the catalog.
193/// 3. Compute the set difference (current - desired - protected) and REVOKE stale grants.
194pub async fn reconcile(
195    client: &Client,
196    executor: &DeploymentExecutor<'_>,
197    obj_id: &ObjectId,
198    grants: &[GrantPrivilegesStatement<Raw>],
199    kind: &GrantObjectKind,
200) -> Result<(), CliError> {
201    for grant in grants {
202        executor.execute_sql(grant).await?;
203    }
204    let current = client
205        .introspection()
206        .get_database_object_grants(
207            kind.catalog_table(),
208            obj_id.expect_database(),
209            obj_id.schema(),
210            obj_id.object(),
211        )
212        .await
213        .map_err(CliError::Connection)?;
214    let default_privs = client
215        .introspection()
216        .get_default_privilege_grants_for_database_object(
217            kind.catalog_table(),
218            obj_id.expect_database(),
219            obj_id.schema(),
220            obj_id.object(),
221            kind.object_type_str(),
222        )
223        .await
224        .map_err(CliError::Connection)?;
225    let protected: BTreeSet<_> = default_privs
226        .iter()
227        .map(|g| (g.grantee.to_lowercase(), g.privilege_type.to_uppercase()))
228        .collect();
229    let desired = desired_grants(grants, kind.all_privileges());
230    let target = kind.grant_target(obj_id);
231    let revocations = stale_grant_revocations(&current, &desired, &protected, &target);
232    execute_revocations(executor, &revocations, kind.label(), obj_id).await
233}
234
235/// Extract `(grantee, privilege_type)` pairs from parsed GRANT statements.
236///
237/// Expands `ALL` privileges based on `all_privileges` (the set of privileges
238/// that `ALL` maps to for the object type).
239pub fn desired_grants(
240    grants: &[GrantPrivilegesStatement<Raw>],
241    all_privileges: &[&str],
242) -> BTreeSet<(String, String)> {
243    let mut result = BTreeSet::new();
244    for grant in grants {
245        let privs: Vec<String> = match &grant.privileges {
246            PrivilegeSpecification::All => all_privileges.iter().map(|p| p.to_string()).collect(),
247            PrivilegeSpecification::Privileges(privs) => {
248                privs.iter().map(|p| p.to_string()).collect()
249            }
250        };
251        for role in &grant.roles {
252            let role_name = role.as_str().to_lowercase();
253            for priv_name in &privs {
254                result.insert((role_name.clone(), priv_name.clone()));
255            }
256        }
257    }
258    result
259}
260
261/// Parse a privilege type string (e.g. `"SELECT"`) into a [`Privilege`] enum value.
262///
263/// Returns `None` for privilege names mz-deploy doesn't recognize, which can
264/// happen if a future Materialize release introduces a new privilege type.
265/// Callers should skip unknown privileges rather than fail outright so the
266/// CLI keeps working against newer servers.
267fn parse_privilege(s: &str) -> Option<Privilege> {
268    let p = if s.eq_ignore_ascii_case("SELECT") {
269        Privilege::SELECT
270    } else if s.eq_ignore_ascii_case("INSERT") {
271        Privilege::INSERT
272    } else if s.eq_ignore_ascii_case("UPDATE") {
273        Privilege::UPDATE
274    } else if s.eq_ignore_ascii_case("DELETE") {
275        Privilege::DELETE
276    } else if s.eq_ignore_ascii_case("USAGE") {
277        Privilege::USAGE
278    } else if s.eq_ignore_ascii_case("CREATE") {
279        Privilege::CREATE
280    } else if s.eq_ignore_ascii_case("CREATEROLE") {
281        Privilege::CREATEROLE
282    } else if s.eq_ignore_ascii_case("CREATEDB") {
283        Privilege::CREATEDB
284    } else if s.eq_ignore_ascii_case("CREATECLUSTER") {
285        Privilege::CREATECLUSTER
286    } else if s.eq_ignore_ascii_case("CREATENETWORKPOLICY") {
287        Privilege::CREATENETWORKPOLICY
288    } else {
289        return None;
290    };
291    Some(p)
292}
293
294/// Compute REVOKE statements for grants that exist in `current` but not in
295/// `desired` and not in `protected` (3-way set difference).
296///
297/// Grantee names are lowercased and privilege types uppercased before comparison
298/// so that catalog casing differences don't cause spurious revocations.
299///
300/// `protected` contains grants that should never be revoked (e.g., grants
301/// originating from `ALTER DEFAULT PRIVILEGES`).
302pub fn stale_grant_revocations(
303    current: &[ObjectGrant],
304    desired: &BTreeSet<(String, String)>,
305    protected: &BTreeSet<(String, String)>,
306    target: &GrantTargetSpecification<Raw>,
307) -> Vec<RevokePrivilegesStatement<Raw>> {
308    let mut revocations = Vec::new();
309    for grant in current {
310        let key = (
311            grant.grantee.to_lowercase(),
312            grant.privilege_type.to_uppercase(),
313        );
314        if desired.contains(&key) || protected.contains(&key) {
315            continue;
316        }
317        let Some(privilege) = parse_privilege(&grant.privilege_type) else {
318            crate::verbose!(
319                "skipping revocation of unknown privilege '{}' on grantee '{}' (target: {:?})",
320                grant.privilege_type,
321                grant.grantee,
322                target,
323            );
324            continue;
325        };
326        revocations.push(RevokePrivilegesStatement {
327            privileges: PrivilegeSpecification::Privileges(vec![privilege]),
328            target: target.clone(),
329            roles: vec![Ident::new_unchecked(grant.grantee.clone())],
330        });
331    }
332    revocations
333}
334
335/// Execute REVOKE statements for stale grants, printing status for each.
336pub async fn execute_revocations(
337    executor: &DeploymentExecutor<'_>,
338    revocations: &[RevokePrivilegesStatement<Raw>],
339    object_type_label: &str,
340    display_name: &impl fmt::Display,
341) -> Result<(), CliError> {
342    let dash_style = Style::new().red().bold();
343    for stmt in revocations {
344        if !executor.is_dry_run() {
345            info!(
346                "  {} Revoking stale grant on {} '{}'",
347                "-".if_supports_color(Stream::Stderr, |t| dash_style.style(t)),
348                object_type_label,
349                display_name,
350            );
351        }
352        executor.execute_sql(stmt).await?;
353    }
354    Ok(())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use mz_sql_parser::ast::Statement;
361    use mz_sql_parser::parser::parse_statements;
362
363    fn make_object_grant(grantee: &str, privilege_type: &str) -> ObjectGrant {
364        ObjectGrant {
365            grantee: grantee.to_string(),
366            privilege_type: privilege_type.to_string(),
367        }
368    }
369
370    /// Parse a GRANT SQL string into a GrantPrivilegesStatement.
371    fn parse_grant(sql: &str) -> GrantPrivilegesStatement<Raw> {
372        let stmts = parse_statements(sql).unwrap();
373        match stmts.into_iter().next().unwrap().ast {
374            Statement::GrantPrivileges(g) => g,
375            other => panic!("expected GRANT, got: {}", other),
376        }
377    }
378
379    fn cluster_target(name: &str) -> GrantTargetSpecification<Raw> {
380        GrantNamedObjectKind::Cluster.grant_target(name)
381    }
382
383    fn network_policy_target(name: &str) -> GrantTargetSpecification<Raw> {
384        GrantNamedObjectKind::NetworkPolicy.grant_target(name)
385    }
386
387    fn obj_id(db: &str, schema: &str, name: &str) -> ObjectId {
388        ObjectId::new(db.to_string(), schema.to_string(), name.to_string())
389    }
390
391    fn table_target(db: &str, schema: &str, name: &str) -> GrantTargetSpecification<Raw> {
392        GrantObjectKind::Table.grant_target(&obj_id(db, schema, name))
393    }
394
395    fn secret_target(db: &str, schema: &str, name: &str) -> GrantTargetSpecification<Raw> {
396        GrantObjectKind::Secret.grant_target(&obj_id(db, schema, name))
397    }
398
399    fn connection_target(db: &str, schema: &str, name: &str) -> GrantTargetSpecification<Raw> {
400        GrantObjectKind::Connection.grant_target(&obj_id(db, schema, name))
401    }
402
403    fn source_target(db: &str, schema: &str, name: &str) -> GrantTargetSpecification<Raw> {
404        GrantObjectKind::Source.grant_target(&obj_id(db, schema, name))
405    }
406
407    /// Convert revocations to strings for easier assertion.
408    fn to_strings(revocations: &[RevokePrivilegesStatement<Raw>]) -> Vec<String> {
409        revocations.iter().map(|r| r.to_string()).collect()
410    }
411
412    #[mz_ore::test]
413    fn test_desired_grants_single_privilege_single_role() {
414        let grant = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO reader");
415        let result = desired_grants(&[grant], &["USAGE", "CREATE"]);
416        assert_eq!(result.len(), 1);
417        assert!(result.contains(&("reader".to_string(), "USAGE".to_string())));
418    }
419
420    #[mz_ore::test]
421    fn test_desired_grants_multiple_privileges() {
422        let grant = parse_grant("GRANT USAGE, CREATE ON CLUSTER my_cluster TO writer");
423        let result = desired_grants(&[grant], &["USAGE", "CREATE"]);
424        assert_eq!(result.len(), 2);
425        assert!(result.contains(&("writer".to_string(), "USAGE".to_string())));
426        assert!(result.contains(&("writer".to_string(), "CREATE".to_string())));
427    }
428
429    #[mz_ore::test]
430    fn test_desired_grants_all_expands_to_object_type_privileges() {
431        let grant = parse_grant("GRANT ALL ON CLUSTER my_cluster TO admin");
432        // For clusters, ALL = USAGE + CREATE
433        let result = desired_grants(&[grant], &["USAGE", "CREATE"]);
434        assert_eq!(result.len(), 2);
435        assert!(result.contains(&("admin".to_string(), "USAGE".to_string())));
436        assert!(result.contains(&("admin".to_string(), "CREATE".to_string())));
437    }
438
439    #[mz_ore::test]
440    fn test_desired_grants_all_with_single_privilege_object_type() {
441        let grant = parse_grant("GRANT ALL ON SECRET \"db\".\"public\".\"my_secret\" TO reader");
442        // For secrets, ALL = USAGE only
443        let result = desired_grants(&[grant], &["USAGE"]);
444        assert_eq!(result.len(), 1);
445        assert!(result.contains(&("reader".to_string(), "USAGE".to_string())));
446    }
447
448    #[mz_ore::test]
449    fn test_desired_grants_multiple_roles() {
450        let grant = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO reader, writer");
451        let result = desired_grants(&[grant], &["USAGE", "CREATE"]);
452        assert_eq!(result.len(), 2);
453        assert!(result.contains(&("reader".to_string(), "USAGE".to_string())));
454        assert!(result.contains(&("writer".to_string(), "USAGE".to_string())));
455    }
456
457    #[mz_ore::test]
458    fn test_desired_grants_multiple_grant_statements() {
459        let g1 = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO reader");
460        let g2 = parse_grant("GRANT CREATE ON CLUSTER my_cluster TO writer");
461        let result = desired_grants(&[g1, g2], &["USAGE", "CREATE"]);
462        assert_eq!(result.len(), 2);
463        assert!(result.contains(&("reader".to_string(), "USAGE".to_string())));
464        assert!(result.contains(&("writer".to_string(), "CREATE".to_string())));
465    }
466
467    #[mz_ore::test]
468    fn test_desired_grants_deduplicates() {
469        // Two grant statements granting the same privilege to the same role
470        let g1 = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO reader");
471        let g2 = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO reader");
472        let result = desired_grants(&[g1, g2], &["USAGE", "CREATE"]);
473        assert_eq!(result.len(), 1);
474    }
475
476    #[mz_ore::test]
477    fn test_desired_grants_empty_input() {
478        let result = desired_grants(&[], &["USAGE", "CREATE"]);
479        assert!(result.is_empty());
480    }
481
482    #[mz_ore::test]
483    fn test_desired_grants_role_name_lowercased() {
484        let grant = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO \"MyRole\"");
485        let result = desired_grants(&[grant], &["USAGE"]);
486        assert!(result.contains(&("myrole".to_string(), "USAGE".to_string())));
487    }
488
489    #[mz_ore::test]
490    fn test_desired_grants_table_all_privileges() {
491        let grant = parse_grant("GRANT ALL ON TABLE \"db\".\"public\".\"my_table\" TO admin");
492        // For tables, ALL = SELECT + INSERT + UPDATE + DELETE
493        let result = desired_grants(&[grant], &["SELECT", "INSERT", "UPDATE", "DELETE"]);
494        assert_eq!(result.len(), 4);
495        assert!(result.contains(&("admin".to_string(), "SELECT".to_string())));
496        assert!(result.contains(&("admin".to_string(), "INSERT".to_string())));
497        assert!(result.contains(&("admin".to_string(), "UPDATE".to_string())));
498        assert!(result.contains(&("admin".to_string(), "DELETE".to_string())));
499    }
500
501    #[mz_ore::test]
502    fn test_stale_grant_revocations_no_stale() {
503        let current = vec![make_object_grant("reader", "USAGE")];
504        let mut desired = BTreeSet::new();
505        desired.insert(("reader".to_string(), "USAGE".to_string()));
506
507        let target = cluster_target("my_cluster");
508        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
509        assert!(revocations.is_empty());
510    }
511
512    #[mz_ore::test]
513    fn test_stale_grant_revocations_has_stale() {
514        let current = vec![
515            make_object_grant("reader", "USAGE"),
516            make_object_grant("writer", "CREATE"),
517        ];
518        let mut desired = BTreeSet::new();
519        desired.insert(("reader".to_string(), "USAGE".to_string()));
520
521        let target = cluster_target("my_cluster");
522        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
523        let strings = to_strings(&revocations);
524        assert_eq!(strings.len(), 1);
525        assert_eq!(
526            strings[0],
527            "REVOKE CREATE ON CLUSTER my_cluster FROM writer"
528        );
529    }
530
531    #[mz_ore::test]
532    fn test_stale_grant_revocations_empty_desired() {
533        let current = vec![make_object_grant("reader", "USAGE")];
534        let desired = BTreeSet::new();
535
536        let target = table_target("db", "public", "t");
537        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
538        let strings = to_strings(&revocations);
539        assert_eq!(strings.len(), 1);
540        assert_eq!(strings[0], "REVOKE USAGE ON TABLE db.public.t FROM reader");
541    }
542
543    #[mz_ore::test]
544    fn test_stale_grant_revocations_empty_current() {
545        let mut desired = BTreeSet::new();
546        desired.insert(("reader".to_string(), "USAGE".to_string()));
547
548        let target = cluster_target("my_cluster");
549        let revocations = stale_grant_revocations(&[], &desired, &BTreeSet::new(), &target);
550        assert!(revocations.is_empty());
551    }
552
553    #[mz_ore::test]
554    fn test_stale_grant_revocations_both_empty() {
555        let target = secret_target("db", "public", "s");
556        let revocations = stale_grant_revocations(&[], &BTreeSet::new(), &BTreeSet::new(), &target);
557        assert!(revocations.is_empty());
558    }
559
560    #[mz_ore::test]
561    fn test_stale_grant_revocations_case_insensitive_match() {
562        // Current has mixed case, desired has lowercase — should still match
563        let current = vec![make_object_grant("Reader", "usage")];
564        let mut desired = BTreeSet::new();
565        desired.insert(("reader".to_string(), "USAGE".to_string()));
566
567        let target = cluster_target("my_cluster");
568        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
569        assert!(revocations.is_empty());
570    }
571
572    #[mz_ore::test]
573    fn test_stale_grant_revocations_multiple_stale() {
574        let current = vec![
575            make_object_grant("reader", "USAGE"),
576            make_object_grant("writer", "CREATE"),
577            make_object_grant("admin", "USAGE"),
578        ];
579        let desired = BTreeSet::new(); // All grants removed
580
581        let target = cluster_target("my_cluster");
582        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
583        assert_eq!(revocations.len(), 3);
584    }
585
586    #[mz_ore::test]
587    fn test_stale_grant_revocations_network_policy_keyword() {
588        let current = vec![make_object_grant("reader", "USAGE")];
589        let desired = BTreeSet::new();
590
591        let target = network_policy_target("my_policy");
592        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
593        let strings = to_strings(&revocations);
594        assert_eq!(strings.len(), 1);
595        assert_eq!(
596            strings[0],
597            "REVOKE USAGE ON NETWORK POLICY my_policy FROM reader"
598        );
599    }
600
601    #[mz_ore::test]
602    fn test_stale_grant_revocations_connection_keyword() {
603        let current = vec![make_object_grant("app", "USAGE")];
604        let desired = BTreeSet::new();
605
606        let target = connection_target("db", "public", "my_conn");
607        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
608        let strings = to_strings(&revocations);
609        assert_eq!(strings.len(), 1);
610        assert_eq!(
611            strings[0],
612            "REVOKE USAGE ON CONNECTION db.public.my_conn FROM app"
613        );
614    }
615
616    #[mz_ore::test]
617    fn test_stale_grant_revocations_secret_keyword() {
618        let current = vec![make_object_grant("app", "USAGE")];
619        let desired = BTreeSet::new();
620
621        let target = secret_target("db", "public", "my_secret");
622        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
623        let strings = to_strings(&revocations);
624        assert_eq!(strings.len(), 1);
625        assert_eq!(
626            strings[0],
627            "REVOKE USAGE ON SECRET db.public.my_secret FROM app"
628        );
629    }
630
631    #[mz_ore::test]
632    fn test_stale_grant_revocations_source_keyword() {
633        let current = vec![make_object_grant("reader", "SELECT")];
634        let desired = BTreeSet::new();
635
636        let target = source_target("db", "public", "my_source");
637        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
638        let strings = to_strings(&revocations);
639        assert_eq!(strings.len(), 1);
640        assert_eq!(
641            strings[0],
642            "REVOKE SELECT ON TABLE db.public.my_source FROM reader"
643        );
644    }
645
646    #[mz_ore::test]
647    fn test_stale_grant_revocations_protected_grants_not_revoked() {
648        // Current has grants for reader (from default privileges) and writer (explicit).
649        // Neither is in desired, but reader's grant is protected.
650        let current = vec![
651            make_object_grant("reader", "SELECT"),
652            make_object_grant("writer", "SELECT"),
653        ];
654        let desired = BTreeSet::new();
655        let mut protected = BTreeSet::new();
656        protected.insert(("reader".to_string(), "SELECT".to_string()));
657
658        let target = table_target("db", "public", "t");
659        let revocations = stale_grant_revocations(&current, &desired, &protected, &target);
660        let strings = to_strings(&revocations);
661        assert_eq!(strings.len(), 1);
662        assert!(strings[0].contains("writer"));
663        assert!(!strings[0].contains("reader"));
664    }
665
666    #[mz_ore::test]
667    fn test_end_to_end_no_revocations_when_grants_match() {
668        let grant = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO reader");
669        let desired = desired_grants(&[grant], &["USAGE", "CREATE"]);
670        let current = vec![make_object_grant("reader", "USAGE")];
671
672        let target = cluster_target("my_cluster");
673        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
674        assert!(revocations.is_empty());
675    }
676
677    #[mz_ore::test]
678    fn test_end_to_end_revoke_removed_grant() {
679        // Project only declares USAGE for reader, but cluster also has CREATE for writer
680        let grant = parse_grant("GRANT USAGE ON CLUSTER my_cluster TO reader");
681        let desired = desired_grants(&[grant], &["USAGE", "CREATE"]);
682        let current = vec![
683            make_object_grant("reader", "USAGE"),
684            make_object_grant("writer", "CREATE"),
685        ];
686
687        let target = cluster_target("my_cluster");
688        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
689        let strings = to_strings(&revocations);
690        assert_eq!(strings.len(), 1);
691        assert!(strings[0].contains("writer"));
692        assert!(strings[0].contains("CREATE"));
693    }
694
695    #[mz_ore::test]
696    fn test_end_to_end_revoke_all_when_grants_removed() {
697        // No grants declared in project, but cluster has grants
698        let desired = desired_grants(&[], &["USAGE", "CREATE"]);
699        let current = vec![
700            make_object_grant("reader", "USAGE"),
701            make_object_grant("writer", "CREATE"),
702        ];
703
704        let target = cluster_target("my_cluster");
705        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
706        assert_eq!(revocations.len(), 2);
707    }
708
709    #[mz_ore::test]
710    fn test_end_to_end_grant_all_covers_all_current() {
711        let grant = parse_grant("GRANT ALL ON CLUSTER my_cluster TO admin");
712        let desired = desired_grants(&[grant], &["USAGE", "CREATE"]);
713        // admin has both USAGE and CREATE — both covered by ALL
714        let current = vec![
715            make_object_grant("admin", "USAGE"),
716            make_object_grant("admin", "CREATE"),
717        ];
718
719        let target = cluster_target("my_cluster");
720        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
721        assert!(revocations.is_empty());
722    }
723
724    #[mz_ore::test]
725    fn test_end_to_end_grant_all_still_revokes_other_roles() {
726        let grant = parse_grant("GRANT ALL ON CLUSTER my_cluster TO admin");
727        let desired = desired_grants(&[grant], &["USAGE", "CREATE"]);
728        // admin is covered, but reader is not in the project file
729        let current = vec![
730            make_object_grant("admin", "USAGE"),
731            make_object_grant("admin", "CREATE"),
732            make_object_grant("reader", "USAGE"),
733        ];
734
735        let target = cluster_target("my_cluster");
736        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
737        let strings = to_strings(&revocations);
738        assert_eq!(strings.len(), 1);
739        assert!(strings[0].contains("reader"));
740    }
741
742    #[mz_ore::test]
743    fn test_end_to_end_multiple_roles_multiple_privileges() {
744        let g1 = parse_grant("GRANT USAGE ON CLUSTER c TO reader");
745        let g2 = parse_grant("GRANT USAGE, CREATE ON CLUSTER c TO writer");
746        let desired = desired_grants(&[g1, g2], &["USAGE", "CREATE"]);
747
748        // Current has an extra admin grant
749        let current = vec![
750            make_object_grant("reader", "USAGE"),
751            make_object_grant("writer", "USAGE"),
752            make_object_grant("writer", "CREATE"),
753            make_object_grant("admin", "USAGE"),
754        ];
755
756        let target = cluster_target("c");
757        let revocations = stale_grant_revocations(&current, &desired, &BTreeSet::new(), &target);
758        let strings = to_strings(&revocations);
759        assert_eq!(strings.len(), 1);
760        assert!(strings[0].contains("admin"));
761    }
762}