mz_deploy/cli/commands/setup.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//! Setup command and connection validation for deployment tracking infrastructure.
11//!
12//! Provides three concerns:
13//! - **`setup()`** — Idempotent, self-healing creation of the `_mz_deploy`
14//! database, tables, views, indexes, and (when RBAC is enabled) roles. The
15//! **only** function that writes to `_mz_deploy`. Invoked exclusively by the
16//! explicit `setup` CLI command. When RBAC is enabled, must run as a
17//! superuser: phase 4 issues `GRANT ... ON SYSTEM` statements (CREATEDB,
18//! CREATECLUSTER) that only a superuser can execute under RBAC. When RBAC
19//! is disabled, the role/grant phase is skipped entirely. Safe to re-run.
20//! - **`verify()`** — Read-only existence check. Every non-`setup` command
21//! calls this and surfaces `CliError::SetupRequired` if the infrastructure
22//! is missing or partially installed. Never writes.
23//! - **`validate_connection()`** — Pre-flight checks that the connected role
24//! has exactly one mz-deploy role membership.
25//! - **`run()`** — The `setup` CLI command entry point.
26
27use crate::cli::error::MissingObject;
28use crate::cli::{CliError, progress};
29use crate::client::{Client, ConnectionError, SERVER_CLUSTER_NAME, quote_identifier};
30use crate::config::Settings;
31use std::collections::BTreeSet;
32
33/// The mz-deploy role assigned to the current database user.
34///
35/// Every non-setup command requires the connected role to be a member of exactly
36/// one of these three roles. Having zero or multiple memberships is an error.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum MzDeployRole {
39 /// A superuser that can run any action
40 Superuser,
41 /// Can apply infrastructure, delete objects, and stage/promote/abort deployments.
42 Deployer,
43 /// Read-only access to deployment state (list, describe, log).
44 Developer,
45 /// Read-only monitoring access to deployment state.
46 Monitor,
47}
48
49impl MzDeployRole {
50 /// Role name as it appears in Materialize.
51 pub fn role_name(&self) -> &'static str {
52 match self {
53 Self::Superuser => "superuser",
54 Self::Deployer => "materialize_deployer",
55 Self::Developer => "materialize_developer",
56 Self::Monitor => "materialize_monitor",
57 }
58 }
59}
60
61impl std::fmt::Display for MzDeployRole {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.write_str(self.role_name())
64 }
65}
66
67/// All mz-deploy roles in check order.
68const ALL_ROLES: &[(MzDeployRole, &str)] = &[
69 (MzDeployRole::Deployer, "materialize_deployer"),
70 (MzDeployRole::Developer, "materialize_developer"),
71 (MzDeployRole::Monitor, "materialize_monitor"),
72];
73
74/// Bring the deployment tracking infrastructure up to the current schema.
75///
76/// The only function in this crate that mutates `_mz_deploy`. Every statement
77/// is idempotent so re-running `setup` against an existing installation
78/// heals drift (missing tables, missing grants) without losing data.
79///
80/// Phases:
81/// 1. Create the `_mz_deploy_server` cluster if missing.
82/// 2. Create the `_mz_deploy` database (`IF NOT EXISTS`).
83/// 3. Run every statement in `SETUP_STATEMENTS` —
84/// each uses `IF NOT EXISTS` so existing objects are left alone. Seed the
85/// version row with a pre-check (no `INSERT IF NOT EXISTS` form).
86/// 4. **RBAC-enabled clusters only**: create the three `materialize_*` roles
87/// if missing and re-apply grants.
88///
89/// When RBAC is disabled — or the profile sets `emulator = true`, which forces
90/// the RBAC-disabled path via `rbac_active` — the role/grant phase is skipped
91/// entirely: no roles are created, no grants are issued, no ownership check is
92/// performed. Without RBAC, `GRANT` statements would have no effect anyway and
93/// downstream commands fall through to the superuser path in
94/// [`validate_connection`].
95///
96/// When RBAC is enabled, `require_superuser` gates phase 4 because the
97/// `GRANT ... ON SYSTEM` statements (CREATEDB, CREATECLUSTER) and `CREATE ROLE`
98/// require it. The superuser also needs:
99/// - Ownership of `_mz_deploy_server` (granted at creation in phase 1) to
100/// `GRANT USAGE` on it.
101/// - `CREATEDB` to create the database.
102/// - `CREATEROLE` to create the roles.
103///
104/// Ordinary commands do **not** call this function — they call
105/// [`verify`] and surface [`CliError::SetupRequired`] if it fails. See the
106/// module docs for the full model.
107pub async fn setup(client: &Client, cluster_size: &str, emulator: bool) -> Result<(), CliError> {
108 let rbac_enabled = rbac_active(client, emulator).await?;
109 if rbac_enabled {
110 require_superuser(client).await?;
111 }
112
113 // Phase 1: server cluster. `CREATE CLUSTER` has no `IF NOT EXISTS` form,
114 // so pre-check. The first create is what makes the calling role the owner,
115 // which is required to GRANT USAGE below.
116 if client
117 .introspection()
118 .get_cluster(SERVER_CLUSTER_NAME)
119 .await?
120 .is_none()
121 {
122 let sql = format!(
123 "CREATE CLUSTER {} (SIZE = '{}')",
124 quote_identifier(SERVER_CLUSTER_NAME),
125 cluster_size,
126 );
127 client.execute(&sql, &[]).await?;
128 }
129
130 // Phase 2: database.
131 client
132 .execute("CREATE DATABASE IF NOT EXISTS _mz_deploy", &[])
133 .await?;
134
135 // Phase 3: schema DDL. Each statement uses `IF NOT EXISTS`. Executed one
136 // at a time — `batch_execute` wraps multi-statement input in an implicit
137 // transaction, which Materialize rejects for DDL.
138 for stmt in super::setup_schema::SETUP_STATEMENTS {
139 client.execute(*stmt, &[]).await?;
140 }
141
142 // Version row is seeded on first setup and left alone thereafter. No
143 // `INSERT IF NOT EXISTS` form exists in Materialize, so pre-check.
144 let has_version: bool = client
145 .query_one(
146 "SELECT EXISTS(SELECT 1 FROM _mz_deploy.tables.version) AS exists",
147 &[],
148 )
149 .await?
150 .get("exists");
151 if !has_version {
152 client
153 .execute("INSERT INTO _mz_deploy.tables.version VALUES (1)", &[])
154 .await?;
155 }
156
157 if !rbac_enabled {
158 // RBAC disabled: skip role creation, ownership check, and grants.
159 // Every role can already do everything; the GRANTs would be no-ops
160 // and would reference roles we deliberately did not create.
161 return Ok(());
162 }
163
164 // Only the database owner can re-run `setup` — the GRANTs below require
165 // ownership. Refuse early with a message naming the owner so a second
166 // admin knows exactly whose hands to transfer ownership from.
167 let owner_row = client
168 .query_one(
169 "SELECT r.name AS owner, current_user() AS current_role \
170 FROM mz_databases d \
171 JOIN mz_roles r ON d.owner_id = r.id \
172 WHERE d.name = '_mz_deploy'",
173 &[],
174 )
175 .await?;
176 let owner: String = owner_row.get("owner");
177 let current_role: String = owner_row.get("current_role");
178 if owner != current_role {
179 return Err(CliError::SetupNotDatabaseOwner {
180 owner,
181 current_role,
182 });
183 }
184
185 // Phase 4: roles + grants. GRANTs are safe to re-run.
186 for (role, role_name) in ALL_ROLES {
187 if !client.introspection().role_exists(role_name).await? {
188 client
189 .execute(&format!("CREATE ROLE {}", role_name), &[])
190 .await?;
191 }
192
193 for sql in [
194 format!(
195 "GRANT USAGE ON CLUSTER {} TO {}",
196 quote_identifier(SERVER_CLUSTER_NAME),
197 role_name
198 ),
199 format!("GRANT USAGE ON DATABASE _mz_deploy TO {}", role_name),
200 format!("GRANT USAGE ON SCHEMA _mz_deploy.public TO {}", role_name),
201 format!("GRANT USAGE ON SCHEMA _mz_deploy.tables TO {}", role_name),
202 format!(
203 "GRANT SELECT ON ALL TABLES IN SCHEMA _mz_deploy.public TO {}",
204 role_name
205 ),
206 ] {
207 client.execute(&sql, &[]).await?;
208 }
209
210 if *role == MzDeployRole::Deployer {
211 // Promote creates short-lived `apply_<deploy_id>_pre` and
212 // `apply_<deploy_id>_post` schemas inside `_mz_deploy` to
213 // serialize the apply-state handshake, so the deployer role
214 // needs CREATE on the database.
215 client
216 .execute(
217 &format!("GRANT CREATE ON DATABASE _mz_deploy TO {}", role_name,),
218 &[],
219 )
220 .await?;
221 client
222 .execute(
223 &format!(
224 "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES \
225 IN SCHEMA _mz_deploy.tables TO {}",
226 role_name,
227 ),
228 &[],
229 )
230 .await?;
231 // `stage` and `apply clusters` create project clusters; `promote`
232 // creates the short-lived apply schemas under `_mz_deploy`.
233 client
234 .execute(
235 &format!("GRANT CREATEDB, CREATECLUSTER ON SYSTEM TO {}", role_name,),
236 &[],
237 )
238 .await?;
239 } else if *role == MzDeployRole::Developer {
240 client
241 .execute(
242 &format!(
243 "GRANT SELECT, INSERT, DELETE \
244 ON TABLE _mz_deploy.tables.dev_overlays TO {}",
245 role_name,
246 ),
247 &[],
248 )
249 .await?;
250 // `dev` creates per-developer overlay databases.
251 client
252 .execute(&format!("GRANT CREATEDB ON SYSTEM TO {}", role_name,), &[])
253 .await?;
254 }
255 }
256
257 Ok(())
258}
259
260/// Check that every object `setup` would create is already present.
261///
262/// Existence-only — never writes, never grants, never checks columns. If the
263/// infrastructure was once fully initialized it stays verified unless
264/// something is dropped out from under us. Upgrading mz-deploy to a release
265/// that adds new tables will trip this check until the admin re-runs
266/// `setup`.
267///
268/// Every non-`setup` command calls this before touching `_mz_deploy`.
269pub async fn verify(client: &Client, emulator: bool) -> Result<(), CliError> {
270 let missing = discover_missing(client, emulator).await?;
271 if missing.is_empty() {
272 return Ok(());
273 }
274 Err(CliError::SetupRequired { missing })
275}
276
277async fn discover_missing(
278 client: &Client,
279 emulator: bool,
280) -> Result<Vec<MissingObject>, ConnectionError> {
281 let mut missing = Vec::new();
282
283 if client
284 .introspection()
285 .get_cluster(SERVER_CLUSTER_NAME)
286 .await?
287 .is_none()
288 {
289 missing.push(MissingObject::Cluster(SERVER_CLUSTER_NAME.to_string()));
290 }
291
292 let db_exists: bool = client
293 .query_one(
294 "SELECT EXISTS(SELECT 1 FROM mz_databases WHERE name = '_mz_deploy') AS exists",
295 &[],
296 )
297 .await?
298 .get("exists");
299
300 if !db_exists {
301 missing.push(MissingObject::Database("_mz_deploy".to_string()));
302 } else {
303 let rows = client
304 .query(
305 "SELECT s.name AS schema_name, o.name AS object_name, o.type AS object_type \
306 FROM mz_objects o \
307 JOIN mz_schemas s ON o.schema_id = s.id \
308 JOIN mz_databases d ON s.database_id = d.id \
309 WHERE d.name = '_mz_deploy' AND s.name IN ('tables', 'public')",
310 &[],
311 )
312 .await?;
313 let present: BTreeSet<(String, String)> = rows
314 .iter()
315 .map(|r| {
316 (
317 r.get::<_, String>("schema_name"),
318 r.get::<_, String>("object_name"),
319 )
320 })
321 .collect();
322
323 for (schema, name, kind) in super::setup_schema::EXPECTED_OBJECTS {
324 if !present.contains(&(schema.to_string(), name.to_string())) {
325 missing.push(MissingObject::SchemaObject {
326 schema: schema.to_string(),
327 name: name.to_string(),
328 kind: kind.to_string(),
329 });
330 }
331 }
332 }
333
334 // `setup` only creates the materialize_* roles when RBAC is enabled, so
335 // only check for them in that mode. Without RBAC, their absence is the
336 // expected state.
337 if rbac_active(client, emulator).await? {
338 for (_role, role_name) in ALL_ROLES {
339 if !client.introspection().role_exists(role_name).await? {
340 missing.push(MissingObject::Role(role_name.to_string()));
341 }
342 }
343 }
344
345 Ok(missing)
346}
347
348/// Validate that the current role has a valid mz-deploy role membership or is a superuser.
349/// Returns the detected role on success.
350///
351/// When RBAC is disabled — or the profile sets `emulator = true` — the role
352/// machinery is meaningless: `setup` never created the `materialize_*` roles,
353/// and every role can already do everything, so this short-circuits to
354/// [`MzDeployRole::Superuser`].
355pub async fn validate_connection(
356 client: &Client,
357 emulator: bool,
358) -> Result<MzDeployRole, CliError> {
359 if !rbac_active(client, emulator).await? {
360 return Ok(MzDeployRole::Superuser);
361 }
362
363 if is_superuser(client).await? {
364 return Ok(MzDeployRole::Superuser);
365 }
366
367 let mut matched_roles = Vec::new();
368 for (role_enum, role_name) in ALL_ROLES {
369 let row = client
370 .query_one(
371 "SELECT pg_has_role(current_role(), $1, 'MEMBER') AS is_member",
372 &[&role_name],
373 )
374 .await?;
375 let is_member: bool = row.get("is_member");
376 if is_member {
377 matched_roles.push(*role_enum);
378 }
379 }
380
381 match matched_roles.len() {
382 0 => Err(CliError::NoMzDeployRole),
383 1 => Ok(matched_roles[0]),
384 _ => Err(CliError::MultipleMzDeployRoles {
385 roles: matched_roles.iter().map(|r| r.to_string()).collect(),
386 }),
387 }
388}
389
390/// Require that the validated role is `Deployer`.
391///
392/// Used by all state-mutating commands: `stage`, `promote`, `abort`,
393/// all `apply` variants, and `delete`.
394pub fn require_deployer(role: MzDeployRole) -> Result<(), CliError> {
395 if role != MzDeployRole::Deployer && role != MzDeployRole::Superuser {
396 return Err(CliError::RoleNotAuthorized {
397 current_role: role.to_string(),
398 required_role: "materialize_deployer".to_string(),
399 });
400 }
401 Ok(())
402}
403
404/// Require that the validated role is `Developer`.
405///
406/// Used by `mz-deploy dev`. Strict — only accepts `Developer`; deployers
407/// should use `stage` instead.
408pub fn require_developer(role: MzDeployRole) -> Result<(), CliError> {
409 if role != MzDeployRole::Developer && role != MzDeployRole::Superuser {
410 return Err(CliError::RoleNotAuthorized {
411 current_role: role.to_string(),
412 required_role: "materialize_developer".to_string(),
413 });
414 }
415 Ok(())
416}
417
418/// Whether RBAC is enforced on the connected cluster.
419///
420/// Both the global `enable_rbac_checks` and the session-level
421/// `enable_session_rbac_checks` must be on for grants and role membership
422/// checks to have any effect. When either is off Materialize allows any role
423/// to do anything, so callers that gate behavior on RBAC use this to decide
424/// whether the role/grant machinery is meaningful. See
425/// <https://materialize.com/docs/security/self-managed/access-control/#enabling-rbac>.
426/// Whether the role/grant machinery is active for this operation.
427///
428/// Emulator profiles force this off even when the server reports RBAC on: the
429/// single-user emulator has no use for the `materialize_*` roles, and `setup`'s
430/// superuser/ownership gates would only get in the way. When `emulator` is
431/// false this is exactly [`is_rbac_enabled`].
432async fn rbac_active(client: &Client, emulator: bool) -> Result<bool, ConnectionError> {
433 Ok(!emulator && is_rbac_enabled(client).await?)
434}
435
436pub async fn is_rbac_enabled(client: &Client) -> Result<bool, ConnectionError> {
437 client
438 .query_one(
439 "SELECT current_setting('enable_rbac_checks')::bool AS rbac_enabled",
440 &[],
441 )
442 .await
443 .map(|r| r.get("rbac_enabled"))
444}
445
446/// Whether the connected role is a superuser (`mz_is_superuser()`).
447async fn is_superuser(client: &Client) -> Result<bool, ConnectionError> {
448 client
449 .query_one("SELECT mz_is_superuser() AS is_superuser", &[])
450 .await
451 .map(|r| r.get("is_superuser"))
452}
453
454/// Require the connecting role be a superuser.
455///
456/// `setup` calls this when RBAC is enabled — phase 4 issues `CREATE ROLE`
457/// and `GRANT ... ON SYSTEM` (CREATEDB, CREATECLUSTER), both of which require
458/// superuser under RBAC. Materialize cloud admin users satisfy this through
459/// the cloud RBAC layer; on a self-hosted cluster only `mz_system` qualifies.
460///
461/// Strict — does not consult RBAC state. Callers that should skip the
462/// superuser check when RBAC is off must gate this call themselves with
463/// [`is_rbac_enabled`].
464async fn require_superuser(client: &Client) -> Result<(), CliError> {
465 let row = client
466 .query_one(
467 "SELECT mz_is_superuser() AS is_superuser, \
468 current_user() AS current_role",
469 &[],
470 )
471 .await?;
472 let is_superuser: bool = row.get("is_superuser");
473 if !is_superuser {
474 let current_role: String = row.get("current_role");
475 return Err(CliError::SetupRequiresSuperuser { current_role });
476 }
477 Ok(())
478}
479
480/// Verify the current role has `CREATEDB` privilege. `mz-deploy dev`
481/// calls this before attempting to create overlay databases.
482///
483/// `sample_overlay_db` is used only for the error message, so the user
484/// sees a concrete name they'd fail to create.
485pub async fn require_createdb(
486 client: &Client,
487 current_role: &str,
488 sample_overlay_db: &str,
489) -> Result<(), CliError> {
490 let row = client
491 .query_one(
492 "SELECT has_system_privilege(current_user, 'CREATEDB') AS has_createdb",
493 &[],
494 )
495 .await?;
496 let has: bool = row.get("has_createdb");
497 if !has {
498 return Err(CliError::MissingCreatedb {
499 role: current_role.to_string(),
500 overlay_db: sample_overlay_db.to_string(),
501 });
502 }
503 Ok(())
504}
505
506/// Initialize deployment tracking database and tables.
507///
508/// Connects to Materialize and creates the `_mz_deploy` database with all
509/// required tracking tables if they don't already exist.
510///
511/// # Arguments
512/// * `settings` - Application settings with connection profile
513///
514/// # Errors
515/// Returns `CliError::Connection` if the database connection fails
516pub async fn run(settings: &Settings, cluster_size: &str) -> Result<(), CliError> {
517 let profile = settings.connection();
518 let client = Client::connect_with_profile_no_pin(profile.clone())
519 .await
520 .map_err(CliError::Connection)?;
521
522 setup(&client, cluster_size, settings.emulator()).await?;
523
524 progress::success("mz_deploy configuration successful");
525 Ok(())
526}