Skip to main content

mz_catalog/durable/upgrade/
v80_to_v81.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
10use crate::durable::upgrade::MigrationAction;
11use crate::durable::upgrade::json_compatible::{JsonCompatible, json_compatible};
12use crate::durable::upgrade::objects_v80 as v80;
13use crate::durable::upgrade::objects_v81 as v81;
14use mz_catalog_protos::objects_v80::{ClusterVariant, ManagedCluster};
15use mz_repr::adt::regex::Regex;
16
17json_compatible!(v80::RoleKey with v81::RoleKey);
18json_compatible!(v80::RoleMembership with v81::RoleMembership);
19json_compatible!(v80::RoleVars with v81::RoleVars);
20
21/// Migrates the catalog role attribute `auto_provision_source` to the new `AutoProvisionSource` enum.
22///
23/// For cloud environments, all existing roles that look like an email address are assumed
24/// to have been provisioned via Frontegg. For self-managed environments, the
25/// field is left as `None` (the JSON default handles this without explicit update actions).
26pub fn upgrade(
27    snapshot: Vec<v80::StateUpdateKind>,
28) -> Vec<MigrationAction<v80::StateUpdateKind, v81::StateUpdateKind>> {
29    // This is a heuristic to determine if the environment is a Materialize Cloud environment
30    // and not a self-managed environment. This heuristic works because by default,
31    // self managed environments have an mz_system cluster with a replication factor of 0.
32    // This was to reduce the hardware requirements for self managed environments. However in
33    // Materialize cloud, we always set the replication factor to 1. Additionally, we also
34    // check if the enable_password_auth system parameter since it's only enabled in Self
35    // Managed environments.
36    let has_password_auth = snapshot.iter().any(|update| match update {
37        v80::StateUpdateKind::ServerConfiguration(config) => {
38            config.key.name == "enable_password_auth" && config.value.value == "on"
39        }
40        _ => false,
41    });
42
43    let is_cloud = snapshot.iter().any(|update| match update {
44        v80::StateUpdateKind::Cluster(cluster) if cluster.value.name == "mz_system" => {
45            if let ClusterVariant::Managed(ManagedCluster {
46                replication_factor, ..
47            }) = cluster.value.config.variant
48            {
49                replication_factor > 0 && !has_password_auth
50            } else {
51                false
52            }
53        }
54        _ => false,
55    });
56
57    if !is_cloud {
58        // Self-managed: auto_provision_source defaults to None
59        return Vec::new();
60    }
61
62    let mut migrations = Vec::new();
63    for update in snapshot {
64        let v80::StateUpdateKind::Role(role) = update else {
65            continue;
66        };
67
68        // This is a heuristic to determine if the role was auto-provisioned via Frontegg.
69        // This works for the vast majority of cases in production. Roles that users
70        // log in to come from Frontegg and therefore *must* be valid email
71        // addresses, while roles that are created via `CREATE ROLE` (e.g.,
72        // `admin`, `prod_app`) almost certainly are not named to look like email
73        // addresses.
74        let email_regex_heuristic = Regex::new(r".+@.+\..+", true).expect("valid regex");
75        let auto_provision_source = if email_regex_heuristic.is_match(&role.value.name.clone()) {
76            Some(v81::AutoProvisionSource::Frontegg)
77        } else {
78            None
79        };
80
81        let new_role = v81::StateUpdateKind::Role(v81::Role {
82            key: JsonCompatible::convert(&role.key),
83            value: v81::RoleValue {
84                name: role.value.name.clone(),
85                attributes: v81::RoleAttributes {
86                    inherit: role.value.attributes.inherit,
87                    superuser: role.value.attributes.superuser,
88                    login: role.value.attributes.login,
89                    auto_provision_source,
90                },
91                membership: JsonCompatible::convert(&role.value.membership),
92                vars: JsonCompatible::convert(&role.value.vars),
93                oid: role.value.oid,
94            },
95        });
96
97        let old_role = v80::StateUpdateKind::Role(role);
98        migrations.push(MigrationAction::Update(old_role, new_role));
99    }
100    migrations
101}
102
103#[cfg(test)]
104mod tests {
105    use super::upgrade;
106    use crate::durable::upgrade::MigrationAction;
107    use crate::durable::upgrade::objects_v80 as v80;
108    use crate::durable::upgrade::objects_v81 as v81;
109
110    fn make_server_configuration(name: &str, value: &str) -> v80::StateUpdateKind {
111        v80::StateUpdateKind::ServerConfiguration(v80::ServerConfiguration {
112            key: v80::ServerConfigurationKey {
113                name: name.to_string(),
114            },
115            value: v80::ServerConfigurationValue {
116                value: value.to_string(),
117            },
118        })
119    }
120
121    fn make_mz_system_cluster(replication_factor: u32) -> v80::StateUpdateKind {
122        v80::StateUpdateKind::Cluster(v80::Cluster {
123            key: v80::ClusterKey {
124                id: v80::ClusterId::System(1),
125            },
126            value: v80::ClusterValue {
127                name: "mz_system".to_string(),
128                owner_id: v80::RoleId::System(1),
129                privileges: vec![],
130                config: v80::ClusterConfig {
131                    workload_class: None,
132                    variant: v80::ClusterVariant::Managed(v80::ManagedCluster {
133                        size: "1".to_string(),
134                        replication_factor,
135                        availability_zones: vec![],
136                        logging: v80::ReplicaLogging {
137                            log_logging: false,
138                            interval: None,
139                        },
140                        optimizer_feature_overrides: vec![],
141                        schedule: v80::ClusterSchedule::Manual,
142                    }),
143                },
144            },
145        })
146    }
147
148    fn make_role(id: u64, name: &str) -> v80::StateUpdateKind {
149        v80::StateUpdateKind::Role(v80::Role {
150            key: v80::RoleKey {
151                id: v80::RoleId::User(id),
152            },
153            value: v80::RoleValue {
154                name: name.to_string(),
155                attributes: v80::RoleAttributes {
156                    inherit: true,
157                    superuser: None,
158                    login: None,
159                },
160                membership: v80::RoleMembership { map: vec![] },
161                vars: v80::RoleVars { entries: vec![] },
162                oid: id.try_into().expect("id fits into u32"),
163            },
164        })
165    }
166
167    #[mz_ore::test]
168    fn test_self_managed_returns_no_migrations() {
169        // We make mz_system cluster with replication factor 0 as heuristic to determine
170        // if the environment is cloud or not.
171        let snapshot = vec![
172            make_server_configuration("enable_password_auth", "on"),
173            make_mz_system_cluster(0),
174            make_role(1, "user@example.com"),
175        ];
176        let migrations = upgrade(snapshot);
177        assert!(migrations.is_empty());
178    }
179
180    #[mz_ore::test]
181    fn test_self_managed_no_password_auth_returns_no_migrations() {
182        // In case a self managed environment has a system cluster with replication factor 1,
183        // we check if the enable_password_auth variable is on given it's only on for self
184        // managed environments.
185        let snapshot = vec![
186            make_server_configuration("enable_password_auth", "on"),
187            make_mz_system_cluster(1),
188            make_role(1, "user@example.com"),
189        ];
190        let migrations = upgrade(snapshot);
191        assert!(migrations.is_empty());
192    }
193
194    #[mz_ore::test]
195    fn test_cloud_mixed_roles() {
196        // Roles that look like email addresses should have autoprovisionsource = 'frontegg'.
197        let snapshot = vec![
198            make_server_configuration("enable_password_auth", "off"),
199            make_mz_system_cluster(1),
200            make_role(1, "user@example.com"),
201            make_role(2, "manually_created_role"),
202        ];
203        let migrations = upgrade(snapshot);
204        assert_eq!(migrations.len(), 2);
205
206        let MigrationAction::Update(_, user_role_action) = &migrations[0] else {
207            panic!("Expected action for user role");
208        };
209        let v81::StateUpdateKind::Role(user_role) = user_role_action else {
210            panic!();
211        };
212        assert_eq!(
213            user_role.value.attributes.auto_provision_source,
214            Some(v81::AutoProvisionSource::Frontegg)
215        );
216
217        let MigrationAction::Update(_, manually_created_role_action) = &migrations[1] else {
218            panic!();
219        };
220        let v81::StateUpdateKind::Role(manually_created_role) = manually_created_role_action else {
221            panic!();
222        };
223        assert_eq!(
224            manually_created_role.value.attributes.auto_provision_source,
225            None
226        );
227    }
228
229    #[mz_ore::test]
230    fn test_non_role_updates_ignored() {
231        // Should ignore Cloud environments with no roles
232        let snapshot = vec![
233            make_server_configuration("enable_password_auth", "off"),
234            make_mz_system_cluster(1),
235        ];
236        let migrations = upgrade(snapshot);
237        assert!(migrations.is_empty());
238    }
239}