Skip to main content

Create a Service User

Once your ALTR account is created, use ALTR’s stored procedure to create your service user and grant privileges.

Note

If you need to create a custom service user, we have very specific instructions. Learn more. 

To create a service user and grant privileges:

  1. Create a database with a well-known name (our recommendation is PC_ALTR_DB) that will house ALTR’s stored procedure.

  2. Create the service user with the name ALTR_SERVICE_USER with a TYPE of LEGACY_SERVICE. The stored procedure creates the role (ALTR_SERVICE_ROLE) and the warehouse (ALTR_SERVICE_WH) if they do not already exist.

    Note

    If your account is created through Snowflake Partner Connect, the TYPE is automatically set to LEGACY_SERVICE. This parameter is required in order for your service user to authenticate with ALTR.

    Starting 09/30/24, all new Snowflake accounts by default require multi-factor authorization (MFA) to log into Snowflake. Set the TYPE parameter to LEGACY_SERVICE on ALTR's service user to ensure ALTR can authenticate with Snowflake. Learn more. ALTR recommends setting a network policy on the service user to prevent unauthorized access. Learn more.

  3. Run the following SnowSQL to set the password to ALTR_SERVICE_USER.

    CREATE USER IF NOT EXISTS ALTR_SERVICE_USER TYPE = LEGACY_SERVICE password = "<Password>";
  4. Run the following SnowSQL in the database to create the ALTR_SETUP_SERVICE_ACCOUNT stored procedure. You should take note of which database you create the procedure in.

    Note

    When you run the stored procedure, set the IS_PARTNER_CONNECT parameter as follows, which depends on the name of your service user and role:

    • If you're using PC_ALTR_USER and PC_ALTR_ROLE (i.e., you’ve used Snowflake Partner Connect to create your ALTR account), set to TRUE

    • If you're using ALTR_SERVICE_USER and ALTR_SERVICE_ROLE (i.e., you manually created your ALTR account), set to FALSE.

    CREATE OR REPLACE PROCEDURE SETUP_ALTR_SERVICE_ACCOUNT(IS_PARTNER_CONNECT BOOLEAN)
    RETURNS STRING
    LANGUAGE JAVASCRIPT
    VOLATILE
    EXECUTE AS CALLER
    AS $$
      // ********************************************
      // Stored Procedure Caller Report
      // ********************************************
      function AltrServiceAccountException(message) {
        this.message = message;
      }
    
      function StoredProcedureReport() {
        this.isSuccess = true;
        this.successMessages = [];
        this.failMessages = [];
        this.unknownDBList = [];
        this.skippedDBList = [];
    
        this.fail = function(message) {
          this.isSuccess = false;
          this.failMessages.push(message);
        }
    
        this.success = function(message) {
          this.successMessages.push(message);
        }
    
        this.unknownDB = function(message) {
          this.unknownDBList.push(message);
        }
    
        this.skipped = function(message) {
          this.skippedDBList.push(message);
        }
    
        this.callerReport = function() {
          let report = '';
    
          function appendMessages(prefix, messages) {
            const totalPrefix = '\n[' + prefix + ']: ';
            for (const message of messages) {
              report += totalPrefix + message;
            }
          }
    
          if (this.isSuccess) {
            report = 'SUCCEEDED!';
          } else {
            report = 'FAILED!';
            appendMessages('FAILURE', this.failMessages);
          }
    
          appendMessages('SUCCESS', this.successMessages);
          appendMessages('SKIPPED', this.skippedDBList);
          appendMessages('UKNOWN OBJECT FOUND', this.unknownDBList);
    
          return report;
        }
      }
    
      var RUN_AS_ALTR = false;
      const storedProcedureReport = new StoredProcedureReport();
    
      // ********************************************
      // Utilities
      // ********************************************
    
      function isNullOrEmpty(str) {
        return typeof('string') !== typeof(str) || str.length < 1;
      }
    
      function tryGetColumnValueAsString(resultSet, columnName) {
        try {
          return resultSet.getColumnValueAsString(columnName);
        } catch (err) {
          return null;
        }
      }
    
      // https://docs.snowflake.com/en/sql-reference/stored-procedures-api.html
      function execQuery(queryString) {
        return snowflake.execute({sqlText:queryString});
      }
    
      function execStatement(queryString, handleError) {
        try {
          const resultSet = execQuery(queryString);
          storedProcedureReport.success(queryString);
          return true;
        } catch (error) {
          let reportFailure = true;
          if ('function' === typeof(handleError)) {
            try {
              reportFailure = handleError(queryString, error);
            } catch (error) {
              reportFailure = true;
            }
          }
          if (reportFailure) {
            storedProcedureReport.fail(queryString + ": " + error);
          }
        }
        return false;
      }
    
      // https://docs.snowflake.com/en/sql-reference/identifiers-syntax.html
      function delimitIdentifier(identifier) {
        return '"' + identifier.replace(/\"/g, '""') + '"';
      }
    
      function getDelimitedComment(objectType) {
        return "'This " + objectType + " is used by ALTR to help simplify governance and control over data in Snowflake. Please do not modify without speaking with ALTR Support.'";
      }
    
      function isUknownDB(queryString, error) {
        const errorString = '' + error;
        let idx = -1;
        idx = errorString.indexOf('Database');
        if (idx !== -1) {
          if (errorString.includes(' does not exist or not authorized.')) {
            storedProcedureReport.unknownDB(errorString.substring(idx));
            return false;
          }
        }
        return true;
      }
    
      // ********************************************
      // Main functionality
      // ********************************************
    
      function permissionRole(delimitedRoleName) {
        // ********************************************
        // Grant account-level privileges on the role
        // ********************************************
    
        let targetRoleExists = true;
        execStatement('GRANT CREATE DATABASE ON ACCOUNT TO ROLE ' + delimitedRoleName, function(queryString, error) {
          const errorString = '' + error;
          if (errorString.includes('Role ') && errorString.includes(' does not exist or not authorized.')) {
            // Use this initial grant to check validity of the input role.
            targetRoleExists = false;
    
            let extraInfo = '';
            const upperCaseTargetRoleName = ALTR_SERVICE_ROLE_NAME.toUpperCase();
            if (ALTR_SERVICE_ROLE_NAME !== upperCaseTargetRoleName) {
              extraInfo = '. Perhaps you meant role ' + upperCaseTargetRoleName;
            }
    
            storedProcedureReport.fail('You called this stored procedure with a role that does not exist: ' + ALTR_SERVICE_ROLE_NAME + extraInfo + '.');
            if (RUN_AS_ALTR) {
              throw new AltrServiceAccountException(storedProcedureReport.callerReport());
            }
            return false;
          } else {
            // Some other error when executing the grant. Report the
            // error, but keep executing.
            return true;
          }
        });
    
        if (!targetRoleExists) return;
    
        execStatement('GRANT APPLY MASKING POLICY ON ACCOUNT TO ROLE ' + delimitedRoleName, function(queryString, error) {
          // This stored procedure is written to execute on all editions of Snowflake,
          // including those that do not support masking policies. So, if we happen
          // to be on the latter, swallow this error silently and move on.
          return !('' + error).includes('Unsupported feature');
        });
    
        execStatement('GRANT CREATE INTEGRATION ON ACCOUNT TO ROLE ' + delimitedRoleName);
        execStatement('GRANT APPLY TAG ON ACCOUNT TO ROLE ' + delimitedRoleName);
        execStatement('GRANT APPLY ROW ACCESS POLICY ON ACCOUNT TO ROLE ' + delimitedRoleName);
    
        // ********************************************
        // Grant privileges on warehouses to role
        // ********************************************
    
        const warehouseNames = [];
    
        let resultSet = execQuery('SHOW WAREHOUSES');
        while (resultSet.next()) {
          warehouseNames.push(resultSet.getColumnValueAsString('name'));
        }
    
        for (const warehouseName of warehouseNames) {
          execStatement('GRANT MONITOR ON WAREHOUSE ' + delimitIdentifier(warehouseName) + ' TO ROLE ' + delimitedRoleName);
        }
    
        // ********************************************
        // Grant database-level privileges to role
        // ********************************************
    
        const databaseNames = [];
    
        resultSet = execQuery('SHOW DATABASES'); // Do not use TERSE here; we need some of the columns it trims
        while (resultSet.next()) {
          const name = resultSet.getColumnValueAsString('name');
          // Exclude TRANSIENT databases. There are no TEMPORARY databases as of this writing,
          // but we might as well exclude them if they every implement them.
          const options = tryGetColumnValueAsString(resultSet, 'options');
          if (!isNullOrEmpty(options)) {
            const optionsUpper = options.toUpperCase();
            if (optionsUpper.includes('TRANSIENT') || optionsUpper.includes('TEMPORARY')) {
              storedProcedureReport.skipped('Database ' + name + ' because it has options: ' + options);
              continue;
            }
          }
    
          // Exclude any non-standard databases (so, shared and native app).
          const kind = tryGetColumnValueAsString(resultSet, 'kind');
          if (!isNullOrEmpty(kind) && !kind.toUpperCase().includes('STANDARD')) {
            storedProcedureReport.skipped('Database ' + name + ' because it is type: ' + kind);
            continue;
          }
    
          // Older versions of Snowflake did not provide us with the "kind"
          // column in SHOW DATABASES, so we had to check the "origin" of the
          // database. If there is an origin, it means the database "comes from
          // somewhere", and is, therefore, a SHARED database.
          const origin = tryGetColumnValueAsString(resultSet, 'origin');
          if (!isNullOrEmpty(origin)) {
            storedProcedureReport.skipped('Database ' + name + ' because it has origin: ' + origin);
            continue;
          }
    
          // Just a regular database, but exclude databases with empty or null names.
          if (!isNullOrEmpty(name)) {
            databaseNames.push(name);
          } else {
            storedProcedureReport.skipped('Database ' + name + ' because it has null or empty name');
          }
        }
    
        for (const databaseName of databaseNames) {
          const delimitedDatabaseName = delimitIdentifier(databaseName);
          let querySuccess = execStatement('GRANT USAGE ON DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          if (querySuccess === false)
            continue;
          execStatement('GRANT CREATE SCHEMA ON DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT USAGE ON FUTURE SCHEMAS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT USAGE ON ALL SCHEMAS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT SELECT ON FUTURE TABLES IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT SELECT ON ALL TABLES IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT SELECT ON FUTURE VIEWS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT SELECT ON ALL VIEWS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT SELECT ON FUTURE MATERIALIZED VIEWS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT SELECT ON ALL MATERIALIZED VIEWS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT CREATE TAG ON FUTURE SCHEMAS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT CREATE TAG ON ALL SCHEMAS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT CREATE ROW ACCESS POLICY ON FUTURE SCHEMAS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
          execStatement('GRANT CREATE ROW ACCESS POLICY ON ALL SCHEMAS IN DATABASE ' + delimitedDatabaseName + ' TO ROLE ' + delimitedRoleName, isUknownDB);
        }
    
        execStatement('CREATE DATABASE IF NOT EXISTS ALTR_DSAAS_DB'); // ownership will be accountadmin
        execStatement('GRANT ALL ON FUTURE SCHEMAS IN DATABASE ALTR_DSAAS_DB TO ROLE ' + delimitedRoleName, isUknownDB);
        execStatement('GRANT ALL ON ALL SCHEMAS IN DATABASE ALTR_DSAAS_DB TO ROLE ' + delimitedRoleName, isUknownDB);
        execStatement('GRANT ALL ON FUTURE FUNCTIONS IN DATABASE ALTR_DSAAS_DB TO ROLE ' + delimitedRoleName, isUknownDB);
        execStatement('GRANT ALL ON ALL FUNCTIONS IN DATABASE ALTR_DSAAS_DB TO ROLE ' + delimitedRoleName, isUknownDB);
        execStatement('GRANT ALL ON DATABASE ALTR_DSAAS_DB TO ROLE ' + delimitedRoleName, isUknownDB);
    
        execStatement('GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ' + delimitedRoleName, isUknownDB);
      }
    
      function permissionUser(delimitedRoleName, delimitedUserName, delimitedWarehouseName) {
        // Create the role for ALTR and assign the role to SYSADMIN as recommended by
        // Snowflake to help with troubleshooting and debugging if necessary.
        execStatement('CREATE ROLE IF NOT EXISTS ' + delimitedRoleName);
        execStatement('ALTER ROLE ' + delimitedRoleName + ' SET COMMENT = ' + getDelimitedComment('role'));
        execStatement('GRANT ROLE ' + delimitedRoleName + ' TO ROLE SYSADMIN');
    
        // Create the warehouse for ALTR and grant usage of it to the ALTR role.
        // Though XSMALL is the default for WAREHOUSE_SIZE and TRUE is the default
        // for AUTO_RESUME, we include them anyway because if the defaults of these
        // properties ever change, there will be serious functionality and cost concerns.
        execStatement('CREATE WAREHOUSE IF NOT EXISTS ' + delimitedWarehouseName
          + ' WITH WAREHOUSE_SIZE = XSMALL SCALING_POLICY = ECONOMY AUTO_RESUME = TRUE INITIALLY_SUSPENDED = TRUE');
        execStatement('ALTER WAREHOUSE ' + delimitedWarehouseName + ' SET AUTO_SUSPEND = 30 COMMENT = ' + getDelimitedComment('warehouse'));
        execStatement('GRANT USAGE ON WAREHOUSE ' + delimitedWarehouseName + ' TO ROLE ' + delimitedRoleName);
    
        // Transfer ownership of the warehouse to customer SYSADMIN. They can then
        // assign resource monitors and other customer-specific configuration.
        // Though this procedure will necessarily run as ACCOUNTADMIN, Snowflake
        // recommeneds that database objects be owned by SYSADMIN.
        execStatement('GRANT OWNERSHIP ON WAREHOUSE ' + delimitedWarehouseName + ' TO ROLE SYSADMIN COPY CURRENT GRANTS');
    
        // Permission the ALTR role. Be sure to do this after you create the
        // ALTR warehouse so the ALTR role gets monitor on that warehouse.
        permissionRole(delimitedRoleName);
    
        // Finally, assign the role to the user (the latter must already be created)
        // and set appropriate defaults.
        execStatement('GRANT ROLE ' + delimitedRoleName + ' TO USER ' + delimitedUserName);
        execStatement('ALTER USER ' + delimitedUserName + ' SET DEFAULT_ROLE = ' + delimitedRoleName);
        execStatement('ALTER USER ' + delimitedUserName + ' SET DEFAULT_WAREHOUSE = ' + delimitedWarehouseName);
        execStatement('ALTER USER ' + delimitedUserName + ' SET COMMENT = ' + getDelimitedComment('user'));
      }
    
      // ********************************************
      // Main
      // ********************************************
    
      let paramsResultSet = execQuery("SHOW PARAMETERS LIKE '%QUERY_TAG%' IN SESSION");
      while (paramsResultSet.next()) {
        let queryTagStr = '';
        queryTagStr = paramsResultSet.getColumnValueAsString('value');
        if (!isNullOrEmpty(queryTagStr) && queryTagStr === "ALTR") {
          RUN_AS_ALTR = true;
          break;
        }
      }
    
      // This script must execute as ACCOUNTADMIN so that
      // we can grant the target role permissions on objects
      // that the target role may not even be able to see.
      try {
        execQuery("USE ROLE ACCOUNTADMIN");
      } catch (error) {
        storedProcedureReport.fail('You must call this stored procedure with a user and role that can assume role ACCOUNTADMIN: ' + error);
        if (RUN_AS_ALTR) {
          throw new AltrServiceAccountException(storedProcedureReport.callerReport());
        }
        return storedProcedureReport.callerReport();
      }
    
      if (IS_PARTNER_CONNECT) {
        execStatement('ALTER WAREHOUSE PC_ALTR_WH SET AUTO_SUSPEND = 30');
        permissionRole('"PC_ALTR_ROLE"');
      } else {
        permissionUser('"ALTR_SERVICE_ROLE"', '"ALTR_SERVICE_USER"', '"ALTR_SERVICE_WH"');
      }
    
      report = storedProcedureReport.callerReport();
      if (storedProcedureReport.isSuccess) {
        execStatement("ALTER PROCEDURE IF EXISTS {{.Database}}.{{.Schema}}.SETUP_ALTR_SERVICE_ACCOUNT(BOOLEAN) SET COMMENT = '" + new Date().toUTCString() + "'");
      } else if (RUN_AS_ALTR) {
        throw new AltrServiceAccountException(report)
      }
      return report;
    $$;
  5. Execute the following command to run the stored procedure. The stored procedure may take several minutes to run, especially for Snowflake accounts with many databases, schemas and tables.

    Note

    If using PC_ALTR_USER, set the parameter to TRUE. If using ALTR_SERVICE_USER, set the parameter to FALSE.

    CALL SETUP_ALTR_SERVICE_ACCOUNT(FALSE);