Skip to main content

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}