diff --git a/Makefile b/Makefile index 863c2b158da..ae4dcc733c7 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ pip_requirements: $(PIP) install --upgrade -r requirements.txt django_migrations: - $(PYTHON) manage.py base_specify_migration --database=migrations + $(PYTHON) manage.py base_specify_migration --use-override --database=migrations $(PYTHON) manage.py migrate --database=migrations specifyweb/settings/build_version.py: .FORCE diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index e52a68dc66e..5cdd9251f8b 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -9,11 +9,9 @@ if [ "$1" = 've/bin/gunicorn' ] || [ "$1" = 've/bin/python' ]; then rsync -a --delete specifyweb/frontend/static/ /volumes/static-files/frontend-static cd /opt/specify7 echo "Applying Django migrations." - set +e - ./sp7_db_setup_check.sh # Setup db users and run mirgations + ./sp7_db_setup_check.sh # Setup db users and run migrations # ve/bin/python manage.py base_specify_migration # ve/bin/python manage.py migrate - ve/bin/python manage.py run_key_migration_functions # Uncomment if you want the key migration functions to run on startup. - set -e + # ve/bin/python manage.py run_key_migration_functions # Uncomment if you want the key migration functions to run on startup. fi exec "$@" diff --git a/sp7_db_setup_check.sh b/sp7_db_setup_check.sh index 652d8514f3b..cfc60d1e4cd 100644 --- a/sp7_db_setup_check.sh +++ b/sp7_db_setup_check.sh @@ -1,25 +1,18 @@ #!/bin/bash +set -euo pipefail echo "Starting MariaDB database and user creation script..." DB_HOST="${DATABASE_HOST}" DB_PORT="${DATABASE_PORT}" - -DB_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD}" - -MASTER_USER_NAME="${MASTER_NAME:-root}" -MASTER_USER_PASSWORD="${MASTER_PASSWORD:-$DB_ROOT_PASSWORD}" -MASTER_USER_HOST="${MASTER_HOST}" - -MIGRATOR_NAME="${MIGRATOR_NAME}" -MIGRATOR_PASSWORD="${MIGRATOR_PASSWORD}" -MIGRATOR_USER_HOST="${MIGRATOR_HOST}" - +MASTER_USER_NAME="${MASTER_NAME:-${MASTER_USER_NAME:-}}" +MASTER_USER_PASSWORD="${MASTER_PASSWORD:-${MASTER_USER_PASSWORD:-}}" +MIGRATOR_NAME="${MIGRATOR_NAME:-}" +MIGRATOR_PASSWORD="${MIGRATOR_PASSWORD:-}" +DB_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" DB_NAME="${DATABASE_NAME}" - -APP_USER_NAME="${APP_USER_NAME}" -APP_USER_PASSWORD="${APP_USER_PASSWORD}" -APP_USER_HOST="${APP_HOST}" +APP_USER_NAME="${APP_USER_NAME:-}" +APP_USER_PASSWORD="${APP_USER_PASSWORD:-}" MASTER_USER_HOST="${MASTER_USER_HOST:-%}" MIGRATOR_USER_HOST="${MIGRATOR_USER_HOST:-%}" @@ -40,15 +33,15 @@ if [[ -z "$MIGRATOR_NAME" ]]; then MIGRATOR_PASSWORD="$DB_ROOT_PASSWORD" fi -# If app user name is not set, set it to migrator +# If target user name is not set, set it to migrator if [[ -z "$APP_USER_NAME" ]]; then APP_USER_NAME="$MIGRATOR_NAME" APP_USER_PASSWORD="$MIGRATOR_PASSWORD" fi # Validate required variables -if [[ -z "$DB_HOST" || -z "$DB_PORT" || -z "$DB_NAME" ]]; then - echo "Error: DB_HOST, DB_PORT, and DB_NAME must be set." +if [[ -z "$DB_HOST" || -z "$DB_PORT" || -z "$MASTER_USER_NAME" || -z "$MASTER_USER_PASSWORD" || -z "$MIGRATOR_PASSWORD" || -z "$DB_NAME" || -z "$APP_USER_NAME" || -z "$APP_USER_PASSWORD" ]]; then + echo "Error: One or more required environment variables are missing or empty." exit 1 fi @@ -64,20 +57,89 @@ fi # Relationship flags between the three users SAME_MASTER_AND_MIGRATOR=false +SAME_MASTER_AND_APP=false +SAME_MIGRATOR_AND_APP=false if [[ "$MIGRATOR_NAME" == "$MASTER_USER_NAME" && "$MIGRATOR_PASSWORD" == "$MASTER_USER_PASSWORD" ]]; then SAME_MASTER_AND_MIGRATOR=true fi - -SAME_MASTER_AND_APP=false if [[ "$APP_USER_NAME" == "$MASTER_USER_NAME" && "$APP_USER_PASSWORD" == "$MASTER_USER_PASSWORD" ]]; then SAME_MASTER_AND_APP=true fi - -SAME_MIGRATOR_AND_APP=false if [[ "$APP_USER_NAME" == "$MIGRATOR_NAME" && "$APP_USER_PASSWORD" == "$MIGRATOR_PASSWORD" ]]; then SAME_MIGRATOR_AND_APP=true fi +sql_string_literal() { + local value="$1" + value=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e "s/'/''/g") + printf "'%s'" "$value" +} + +sql_identifier() { + local value="$1" + value=$(printf '%s' "$value" | sed -e 's/`/``/g') + printf "\`%s\`" "$value" +} + +regex_escape() { + local value="$1" + printf '%s' "$value" | sed -e 's/[][\/.^$*+?{}()|\\]/\\&/g' +} + +has_all_privs_in_line() { + local line="$1" + shift + local missing=() + local p + for p in "$@"; do + # match whole words; spaces already canonicalized + if ! grep -qiE "(^|[, ])${p}(,| |$)" <<<"$line"; then + missing+=("$p") + fi + done + if [[ ${#missing[@]} -eq 0 ]]; then + return 0 + else + return 1 + fi +} + +grant_line_has_required_privs() { + local line="$1" + shift + + if grep -qiE "^GRANT .*ALL PRIVILEGES.* ON \*\.\* TO " <<<"$line"; then + return 0 + fi + + if grep -qiE "^GRANT .*ALL PRIVILEGES.* ON (${SQL_DB_IDENTIFIER_REGEX}|${DB_NAME_REGEX})\.\* TO " <<<"$line"; then + return 0 + fi + + if grep -qiE " ON \*\.\* TO " <<<"$line" && has_all_privs_in_line "$line" "$@"; then + return 0 + fi + + if grep -qiE " ON (${SQL_DB_IDENTIFIER_REGEX}|${DB_NAME_REGEX})\.\* TO " <<<"$line" && has_all_privs_in_line "$line" "$@"; then + return 0 + fi + + return 1 +} + +SQL_DB_NAME=$(sql_string_literal "$DB_NAME") +SQL_DB_IDENTIFIER=$(sql_identifier "$DB_NAME") +SQL_MIGRATOR_NAME=$(sql_string_literal "$MIGRATOR_NAME") +SQL_MIGRATOR_PASSWORD=$(sql_string_literal "$MIGRATOR_PASSWORD") +SQL_MIGRATOR_USER_HOST=$(sql_string_literal "$MIGRATOR_USER_HOST") +SQL_APP_USER_NAME=$(sql_string_literal "$APP_USER_NAME") +SQL_APP_USER_PASSWORD=$(sql_string_literal "$APP_USER_PASSWORD") +SQL_APP_USER_HOST=$(sql_string_literal "$APP_USER_HOST") +DB_NAME_REGEX=$(regex_escape "$DB_NAME") +SQL_DB_IDENTIFIER_REGEX=$(regex_escape "$SQL_DB_IDENTIFIER") +MIGRATION_REQUIRED_PRIVS=("SELECT" "INSERT" "UPDATE" "DELETE" "CREATE" "ALTER" "INDEX" "DROP") +APP_REQUIRED_PRIVS=("SELECT" "INSERT" "UPDATE" "DELETE" "CREATE TEMPORARY TABLES" "LOCK TABLES" "EXECUTE") + echo "--------------------------------------------------" echo "DB Configuration:" echo " DB Host: $DB_HOST" @@ -121,14 +183,14 @@ else fi # Create database if it doesn't exist -DB_EXISTS=$(mariadb -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -sse "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '$DB_NAME';") +DB_EXISTS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -sse \ +"SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '$DB_NAME';") if [[ "$DB_EXISTS" -eq 0 ]]; then echo "Creating database '$DB_NAME'..." - echo "Executing: mariadb -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE DATABASE \`$DB_NAME\`;\"" - if mariadb -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "CREATE DATABASE \`$DB_NAME\`;"; then + echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE DATABASE ${SQL_DB_IDENTIFIER};\"" + if mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ + -e "CREATE DATABASE ${SQL_DB_IDENTIFIER};"; then NEW_DATABASE_CREATED=1 else echo "Error: Failed to create database." @@ -138,132 +200,79 @@ else echo "Database '$DB_NAME' already exists." fi -######################################## -# MIGRATOR USER -######################################## +# Create migrator user if it doesn't exist +USER_EXISTS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -sse \ +"SELECT COUNT(*) FROM mysql.user WHERE user = $SQL_MIGRATOR_NAME AND host = $SQL_MIGRATOR_USER_HOST;") -if [[ "$SAME_MASTER_AND_MIGRATOR" == true ]]; then - echo "Migrator user '$MIGRATOR_NAME' uses the same credentials as master." - echo "Skipping creation/grant steps for a separate migrator account and using master connection for migrations." - MIGRATION_DB_ALIAS="master" +if [[ "$USER_EXISTS" -eq 0 && "$MIGRATOR_NAME" != "root" ]]; then + echo "Creating migrator user '$MIGRATOR_NAME'..." + echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE USER '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}' IDENTIFIED BY '';\"" + if mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ + -e "CREATE USER $SQL_MIGRATOR_NAME@$SQL_MIGRATOR_USER_HOST IDENTIFIED BY $SQL_MIGRATOR_PASSWORD;"; then + NEW_MIGRATOR_USER_CREATED=1 + else + echo "Error: Failed to create user." + exit 1 + fi else - echo "Ensuring migrator user '$MIGRATOR_NAME' exists for relevant hosts..." - - MIGRATOR_HOSTS_SEEN="" - # Ensure user exists for both MIGRATOR_USER_HOST and CLIENT_HOST - for h in "$MIGRATOR_USER_HOST" "$CLIENT_HOST"; do - [[ -z "$h" ]] && continue - if [[ " $MIGRATOR_HOSTS_SEEN " == *" $h "* ]]; then - continue - fi - MIGRATOR_HOSTS_SEEN+=" $h" - - USER_EXISTS_HOST=$(mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -sse "SELECT COUNT(*) FROM mysql.user WHERE user = '$MIGRATOR_NAME' AND host = '$h';") - - if [[ "$USER_EXISTS_HOST" -eq 0 ]]; then - echo "Creating user '${MIGRATOR_NAME}'@'${h}'..." - echo "Executing: CREATE USER '${MIGRATOR_NAME}'@'${h}' IDENTIFIED BY ''" - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "CREATE USER '${MIGRATOR_NAME}'@'${h}' IDENTIFIED BY '${MIGRATOR_PASSWORD}';"; then - echo "Error: Failed to create migrator user '${MIGRATOR_NAME}'@'${h}'." - exit 1 - fi - NEW_MIGRATOR_USER_CREATED=1 - else - echo "Migrator user '${MIGRATOR_NAME}'@'${h}' already exists." - fi - done + echo "User '$MIGRATOR_NAME' already exists." +fi - echo "Existing hosts for '$MIGRATOR_NAME' in mysql.user:" - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SELECT CONCAT(\"'\", user, \"'@'\", host, \"'\") FROM mysql.user WHERE user = '$MIGRATOR_NAME';"; then - echo "Warning: Could not list existing hosts for '$MIGRATOR_NAME'." +if [[ "$NEW_MIGRATOR_USER_CREATED" -eq 1 ]]; then + echo "Granting privileges to new user..." + echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"GRANT ALL PRIVILEGES ON ${SQL_DB_IDENTIFIER}.* TO ${SQL_MIGRATOR_NAME}@${SQL_MIGRATOR_USER_HOST}; FLUSH PRIVILEGES;\"" + if ! mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -e "GRANT ALL PRIVILEGES ON ${SQL_DB_IDENTIFIER}.* TO ${SQL_MIGRATOR_NAME}@${SQL_MIGRATOR_USER_HOST}; FLUSH PRIVILEGES;"; then + echo "Error: Failed to grant privileges to new user." + exit 1 fi +else + echo "Skipping privilege grant for migrator user: user already exists. Verifying privileges on '${DB_NAME}'..." +fi - # Grant privileges on DB_NAME for all relevant migrator hosts - echo "Granting privileges to migrator user '$MIGRATOR_NAME'..." - MIGRATOR_GRANT_HOSTS_SEEN="" - for h in "$MIGRATOR_USER_HOST" "$CLIENT_HOST"; do - [[ -z "$h" ]] && continue - if [[ " $MIGRATOR_GRANT_HOSTS_SEEN " == *" $h "* ]]; then - continue - fi - MIGRATOR_GRANT_HOSTS_SEEN+=" $h" - - echo "Granting ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${MIGRATOR_NAME}'@'${h}'..." - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${MIGRATOR_NAME}'@'${h}';"; then - echo "Error: Failed to grant privileges to migrator user '${MIGRATOR_NAME}'@'${h}'." - echo "--------------" - echo "GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${MIGRATOR_NAME}'@'${h}'" - echo "--------------" - exit 1 - fi - done +GRANTS_OUTPUT="$(mysql -N -B -h "$DB_HOST" -P "$DB_PORT" \ + -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ + -e "SHOW GRANTS FOR '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}';" 2>/dev/null || true)" - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "FLUSH PRIVILEGES;"; then - echo "Error: Failed to FLUSH PRIVILEGES for migrator user." - exit 1 - fi +if [[ -z "$GRANTS_OUTPUT" ]]; then + echo "Error: Could not retrieve grants for '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}'." + exit 1 +fi - # Verify migrator access for MIGRATOR_USER_HOST - GRANTS_OUTPUT="$(mariadb -N -B -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SHOW GRANTS FOR '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}';" 2>/dev/null || true)" +mapfile -t MIGRATOR_GRANTS_LINES < <(echo "$GRANTS_OUTPUT" | tr -s '[:space:]' ' ') - if [[ -z "$GRANTS_OUTPUT" ]]; then - echo "Error: Could not retrieve grants for '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}'." - echo "Check whether this user exists with a different host (e.g. 'localhost' instead of '$MIGRATOR_USER_HOST')." - exit 1 - fi +migrator_has_required_permissions=false - migrator_has_access=false - while IFS= read -r raw_line; do - line="$(echo "$raw_line" | tr -s '[:space:]' ' ')" - if echo "$line" | grep -Eiq " ON (\*\.\*|(\`?${DB_NAME}\`?)\.\*) "; then - privs="$(echo "$line" | sed -E 's/^GRANT (.+) ON .+$/\1/I')" - if echo "$privs" | grep -Eiq '^[[:space:]]*USAGE[[:space:]]*$'; then - continue - fi - migrator_has_access=true - break - fi - done <<< "$GRANTS_OUTPUT" - - # Also verify for CLIENT_HOST if available and different - if [[ -n "$CLIENT_HOST" && "$CLIENT_HOST" != "$MIGRATOR_USER_HOST" ]]; then - CLIENT_GRANTS_OUTPUT="$(mariadb -N -B -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SHOW GRANTS FOR '${MIGRATOR_NAME}'@'${CLIENT_HOST}';" 2>/dev/null || true)" - while IFS= read -r raw_line; do - line="$(echo "$raw_line" | tr -s '[:space:]' ' ')" - if echo "$line" | grep -Eiq " ON (\*\.\*|(\`?${DB_NAME}\`?)\.\*) "; then - privs="$(echo "$line" | sed -E 's/^GRANT (.+) ON .+$/\1/I')" - if echo "$privs" | grep -Eiq '^[[:space:]]*USAGE[[:space:]]*$'; then - continue - fi - migrator_has_access=true - break - fi - done <<< "$CLIENT_GRANTS_OUTPUT" +for g in "${MIGRATOR_GRANTS_LINES[@]}"; do + if grant_line_has_required_privs "$g" "${MIGRATION_REQUIRED_PRIVS[@]}"; then + migrator_has_required_permissions=true; break fi +done + +if [[ "$migrator_has_required_permissions" == true ]]; then + echo "Verified: '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}' has migration privileges on '${DB_NAME}'." +else + echo "Error: '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}' lacks migration privileges on '${DB_NAME}'." + echo "Required for migrations (any one GRANT must include all of): ${MIGRATION_REQUIRED_PRIVS[*]}" + echo "Grants found:" + echo "$GRANTS_OUTPUT" + exit 1 +fi + +# Create app user if it doesn't exist +USER_EXISTS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -sse \ +"SELECT COUNT(*) FROM mysql.user WHERE user = $SQL_APP_USER_NAME AND host = $SQL_APP_USER_HOST;") - if [[ "$migrator_has_access" == true ]]; then - echo "Verified: migrator user '${MIGRATOR_NAME}' has usable access to '${DB_NAME}'." +if [[ "$USER_EXISTS" -eq 0 && "$APP_USER_NAME" != "root" ]]; then + echo "Creating app user '$APP_USER_NAME'..." + echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE USER '${APP_USER_NAME}'@'${APP_USER_HOST}' IDENTIFIED BY '';\"" + if mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ + -e "CREATE USER $SQL_APP_USER_NAME@$SQL_APP_USER_HOST IDENTIFIED BY $SQL_APP_USER_PASSWORD;"; then + NEW_APP_USER_CREATED=1 else - echo "Notice: Migrator user '${MIGRATOR_NAME}' lacks usable access to '${DB_NAME}'." - echo "Make corrections to the intended MIGRATOR user permissions to resolve." - echo " GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}'; FLUSH PRIVILEGES;" - MIGRATOR_NAME="$MASTER_USER_NAME" - MIGRATOR_PASSWORD="$MASTER_USER_PASSWORD" - MIGRATION_DB_ALIAS="master" + echo "Error: Failed to create app user '${APP_USER_NAME}'@'${APP_USER_HOST}'." + echo "Falling back to migrator credentials for app user." + APP_USER_NAME="$MIGRATOR_NAME" + APP_USER_PASSWORD="$MIGRATOR_PASSWORD" fi fi @@ -284,135 +293,46 @@ elif [[ "$SAME_MIGRATOR_AND_APP" == true ]]; then else echo "Ensuring app user '$APP_USER_NAME' exists for relevant hosts..." - APP_HOSTS_SEEN="" - for h in "$APP_USER_HOST" "$CLIENT_HOST"; do - [[ -z "$h" ]] && continue - if [[ " $APP_HOSTS_SEEN " == *" $h "* ]]; then - continue - fi - APP_HOSTS_SEEN+=" $h" - - USER_EXISTS_HOST=$(mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -sse "SELECT COUNT(*) FROM mysql.user WHERE user = '$APP_USER_NAME' AND host = '$h';") - - if [[ "$USER_EXISTS_HOST" -eq 0 ]]; then - echo "Creating user '${APP_USER_NAME}'@'${h}'..." - echo "Executing: CREATE USER '${APP_USER_NAME}'@'${h}' IDENTIFIED BY ''" - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "CREATE USER '${APP_USER_NAME}'@'${h}' IDENTIFIED BY '${APP_USER_PASSWORD}';"; then - echo "Error: Failed to create app user '${APP_USER_NAME}'@'${h}'." - exit 1 - fi - NEW_APP_USER_CREATED=1 - else - echo "App user '${APP_USER_NAME}'@'${h}' already exists." - fi - done - - echo "Existing hosts for '$APP_USER_NAME' in mysql.user:" - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SELECT CONCAT(\"'\", user, \"'@'\", host, \"'\") FROM mysql.user WHERE user = '$APP_USER_NAME';"; then - echo "Warning: Could not list existing hosts for '$APP_USER_NAME'." - fi - - REQUIRED_PRIVS=("SELECT" "INSERT" "UPDATE" "ALTER" "INDEX" "DELETE" "CREATE TEMPORARY TABLES" "LOCK TABLES" "EXECUTE") - - echo "Granting required privileges to app user '$APP_USER_NAME' for relevant hosts..." - APP_GRANT_HOSTS_SEEN="" - for h in "$APP_USER_HOST" "$CLIENT_HOST"; do - [[ -z "$h" ]] && continue - if [[ " $APP_GRANT_HOSTS_SEEN " == *" $h "* ]]; then - continue - fi - APP_GRANT_HOSTS_SEEN+=" $h" - - echo "Granting app privileges on \`${DB_NAME}\`.* to '${APP_USER_NAME}'@'${h}'..." - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "GRANT SELECT, INSERT, UPDATE, ALTER, INDEX, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON \`${DB_NAME}\`.* TO '${APP_USER_NAME}'@'${h}';"; then - echo "Error: Failed to grant privileges to app user '${APP_USER_NAME}'@'${h}'." - echo "--------------" - echo "GRANT SELECT, INSERT, UPDATE, ALTER, INDEX, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON \`${DB_NAME}\`.* TO '${APP_USER_NAME}'@'${h}';" - echo "--------------" - exit 1 - fi - done - - if ! mariadb -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "FLUSH PRIVILEGES;"; then - echo "Error: Failed to FLUSH PRIVILEGES for app user." - exit 1 - fi - - APP_GRANTS_RAW="$(mariadb -N -B -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SHOW GRANTS FOR '${APP_USER_NAME}'@'${APP_USER_HOST}';" 2>/dev/null || true)" - - if [[ -z "$APP_GRANTS_RAW" ]]; then - echo "Error: Could not retrieve grants for '${APP_USER_NAME}'@'${APP_USER_HOST}'." - echo "Check whether this user exists only with different hosts (e.g. 'localhost' instead of '$APP_USER_HOST')." +if [[ "$NEW_APP_USER_CREATED" -eq 1 ]]; then + echo "Granting privileges to new user..." + echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON ${SQL_DB_IDENTIFIER}.* TO ${SQL_APP_USER_NAME}@${SQL_APP_USER_HOST}; FLUSH PRIVILEGES;\"" + if ! mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -e "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON ${SQL_DB_IDENTIFIER}.* TO ${SQL_APP_USER_NAME}@${SQL_APP_USER_HOST}; FLUSH PRIVILEGES;"; then + echo "Error: Failed to grant privileges to new user." exit 1 fi +else + echo "Skipping privilege grant for app user: user already exists. Verifying privileges on '${DB_NAME}'..." +fi - mapfile -t APP_GRANTS_LINES < <(printf '%s\n' "$APP_GRANTS_RAW" | sed 's/[[:space:]]\+/ /g') +APP_GRANTS_RAW="$(mysql -N -B -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ + -e "SHOW GRANTS FOR '${APP_USER_NAME}'@'${APP_USER_HOST}';" 2>/dev/null || true)" - app_has_required_permissions=false +if [[ -z "$APP_GRANTS_RAW" ]]; then + echo "Error: Could not retrieve grants for '${APP_USER_NAME}'@'${APP_USER_HOST}'." + exit 1 +fi - has_all_privs_in_line() { - local line="$1" - local missing=() - for p in "${REQUIRED_PRIVS[@]}"; do - if ! grep -qiE "(^|[, ])${p}(,| |$)" <<<"$line"; then - missing+=("$p") - fi - done - [[ ${#missing[@]} -eq 0 ]] - } +mapfile -t APP_GRANTS_LINES < <(echo "$APP_GRANTS_RAW" | tr -s '[:space:]' ' ') - for g in "${APP_GRANTS_LINES[@]}"; do - if grep -qiE "^GRANT .*ALL PRIVILEGES.* ON \*\.\* TO " <<<"$g"; then - app_has_required_permissions=true; break - fi - if grep -qiE " ON \*\.\* TO " <<<"$g" && has_all_privs_in_line "$g"; then - app_has_required_permissions=true; break - fi - if grep -qiE " ON (\`?${DB_NAME}\`?)\.\* TO " <<<"$g" && has_all_privs_in_line "$g"; then - app_has_required_permissions=true; break - fi - done +app_has_required_permissions=false - # Verify at CLIENT_HOST if different - if [[ "$app_has_required_permissions" != true && -n "$CLIENT_HOST" && "$CLIENT_HOST" != "$APP_USER_HOST" ]]; then - CLIENT_APP_GRANTS_RAW="$(mariadb -N -B -h "$DB_HOST" -P "$DB_PORT" \ - -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SHOW GRANTS FOR '${APP_USER_NAME}'@'${CLIENT_HOST}';" 2>/dev/null || true)" - mapfile -t CLIENT_APP_GRANTS_LINES < <(printf '%s\n' "$CLIENT_APP_GRANTS_RAW" | sed 's/[[:space:]]\+/ /g') - for g in "${CLIENT_APP_GRANTS_LINES[@]}"; do - if grep -qiE "^GRANT .*ALL PRIVILEGES.* ON \*\.\* TO " <<<"$g"; then - app_has_required_permissions=true; break - fi - if grep -qiE " ON \*\.\* TO " <<<"$g" && has_all_privs_in_line "$g"; then - app_has_required_permissions=true; break - fi - if grep -qiE " ON (\`?${DB_NAME}\`?)\.\* TO " <<<"$g" && has_all_privs_in_line "$g"; then - app_has_required_permissions=true; break - fi - done +# Evaluate each grant line +for g in "${APP_GRANTS_LINES[@]}"; do + if grant_line_has_required_privs "$g" "${APP_REQUIRED_PRIVS[@]}"; then + app_has_required_permissions=true; break fi +done - if [[ "$app_has_required_permissions" == true ]]; then - echo "Verified: app user '${APP_USER_NAME}' has required privileges on '${DB_NAME}'." - else - echo "Error: '${APP_USER_NAME}' lacks required privileges on '${DB_NAME}'." - echo "Required (any one GRANT must include all of): ${REQUIRED_PRIVS[*]}" - echo "Grants found (APP_USER_HOST):" - echo "$APP_GRANTS_RAW" - APP_USER_NAME="$MIGRATOR_NAME" - APP_USER_PASSWORD="$MIGRATOR_PASSWORD" - fi +if [[ "$app_has_required_permissions" == true ]]; then + echo "Verified: '${APP_USER_NAME}'@'${APP_USER_HOST}' has required privileges on '${DB_NAME}'." +else + echo "Error: '${APP_USER_NAME}'@'${APP_USER_HOST}' lacks required privileges on '${DB_NAME}'." + echo "Required (any one GRANT must include all of): ${APP_REQUIRED_PRIVS[*]}" + echo "Grants found:" + echo "$APP_GRANTS_RAW" + APP_USER_NAME="$MIGRATOR_NAME" + APP_USER_PASSWORD="$MIGRATOR_PASSWORD" +fi fi echo "--------------------------------------------------" @@ -423,13 +343,24 @@ echo "New app user created: $([[ "$NEW_APP_USER_CREATED" -eq 1 ]] && echo True | echo "--------------------------------------------------" # Run the base_specify_migration script +echo "Running base_specify_migration..." + if [[ "$NEW_DATABASE_CREATED" -eq 0 ]]; then - echo "Existing database detected." + set +e ve/bin/python manage.py base_specify_migration --use-override --database=${MIGRATION_DB_ALIAS} + BASE_MIGRATION_EXIT_CODE=$? + set -e else - echo "New database detected." + set +e ve/bin/python manage.py base_specify_migration --database=${MIGRATION_DB_ALIAS} + BASE_MIGRATION_EXIT_CODE=$? + set -e +fi + +if [[ $BASE_MIGRATION_EXIT_CODE -ne 0 ]]; then + echo "Error: base_specify_migration failed (exit code $BASE_MIGRATION_EXIT_CODE). Aborting." + exit 1 fi -# Run Django migrations +echo "Running Django migrations..." ve/bin/python manage.py migrate --database=${MIGRATION_DB_ALIAS} diff --git a/specifyweb/backend/businessrules/uniqueness_rules.py b/specifyweb/backend/businessrules/uniqueness_rules.py index 34ec4af7857..9cd79b4c50c 100644 --- a/specifyweb/backend/businessrules/uniqueness_rules.py +++ b/specifyweb/backend/businessrules/uniqueness_rules.py @@ -395,15 +395,15 @@ def create_uniqueness_rule(model_name: str, discipline, is_database_constraint: UniquenessRuleField = registry.get_model( 'businessrules', 'UniquenessRuleField') if registry else models.UniquenessRuleField + fields = list(fields) + scopes = list(scopes) + final_discipline = None if rule_is_global(scopes) else discipline candidate_rules = UniquenessRule.objects.filter(modelName=model_name, isDatabaseConstraint=is_database_constraint, discipline=final_discipline) - fields = list(fields) - scopes = list(scopes) - for rule in candidate_rules: # If the rule already exists, skip creating the rule if _rule_fields_match( diff --git a/specifyweb/backend/permissions/initialize.py b/specifyweb/backend/permissions/initialize.py index a9cf2c62d5b..28814171dd0 100644 --- a/specifyweb/backend/permissions/initialize.py +++ b/specifyweb/backend/permissions/initialize.py @@ -32,22 +32,26 @@ def is_sp6_user_permissions_migrated(user, apps=apps) -> bool: return UserRole.objects.filter(specifyuser=user).exists() or \ UserPolicy.objects.filter(specifyuser=user).exists() -def initialize( - wipe: bool = False, - apps=apps, - *, - migrate_sp6_users: bool = True, -) -> None: +def has_sp6_permissions_tables() -> bool: + with connection.cursor() as cursor: + cursor.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_name IN ('specifyuser_spprincipal', 'spuserrole') + AND table_schema = DATABASE(); + """) + return cursor.fetchone()[0] == 2 + +def initialize(wipe: bool=False, apps=apps) -> None: with transaction.atomic(): if wipe: wipe_permissions(apps) create_admins(apps) create_roles(apps) - if migrate_sp6_users: - if 'test' in ''.join(sys.argv): - assign_users_to_roles_during_testing(apps) - else: - assign_users_to_roles(apps) + if 'test' in ''.join(sys.argv): + assign_users_to_roles_during_testing(apps) + else: + assign_users_to_roles(apps) def create_admins(apps=apps) -> None: UserPolicy = apps.get_model('permissions', 'UserPolicy') @@ -91,15 +95,10 @@ def assign_users_to_roles(apps=apps) -> None: } results = [] + if not has_sp6_permissions_tables(): + return # Newly created sp7 databases don't have these sp6 specific tables. + with connection.cursor() as cursor: - cursor.execute(""" - SELECT COUNT(*) - FROM information_schema.tables - WHERE table_name IN ('specifyuser_spprincipal', 'spuserrole') - AND table_schema = DATABASE(); - """) - if cursor.fetchone()[0] < 2: - return # Newly created sp7 databases don't have these sp6 specific tables. cursor.execute(""" SELECT u.SpecifyUserID as user_id, @@ -165,7 +164,6 @@ def assign_users_to_roles_during_testing(apps=apps) -> None: Specifyuser = apps.get_model('specify', 'Specifyuser') Agent = apps.get_model('specify', 'Agent') - cursor = connection.cursor() for user in Specifyuser.objects.all(): for collection in Collection.objects.all(): if user.usertype == 'Manager': @@ -175,16 +173,21 @@ def assign_users_to_roles_during_testing(apps=apps) -> None: if user.usertype in ('LimitedAccess', 'Guest'): user.roles.create(role=Role.objects.get(collection=collection, name="Read Only - Legacy")) - for colid, _ in users_collections_for_sp6(cursor, user.id): - # Does the user has an agent for the collection? - if Agent.objects.filter(specifyuser=user, division__disciplines__collections__id=colid).exists(): - # Give them access to the collection. - UserPolicy.objects.create( - collection_id=colid, - specifyuser_id=user.id, - resource=CollectionAccessPT.access.resource(), - action=CollectionAccessPT.access.action(), - ) + if not has_sp6_permissions_tables(): + return + + with connection.cursor() as cursor: + for user in Specifyuser.objects.all(): + for colid, _ in users_collections_for_sp6(cursor, user.id): + # Does the user has an agent for the collection? + if Agent.objects.filter(specifyuser=user, division__disciplines__collections__id=colid).exists(): + # Give them access to the collection. + UserPolicy.objects.get_or_create( + collection_id=colid, + specifyuser_id=user.id, + resource=CollectionAccessPT.access.resource(), + action=CollectionAccessPT.access.action(), + ) def create_roles(apps = apps) -> None: LibraryRole = apps.get_model('permissions', 'LibraryRole') @@ -548,4 +551,4 @@ def create_roles(apps = apps) -> None: ) if is_new: for lp in collection_admin.policies.all(): - ca.policies.get_or_create(resource=lp.resource, action=lp.action) + ca.policies.get_or_create(resource=lp.resource, action=lp.action) \ No newline at end of file diff --git a/specifyweb/settings/__init__.py b/specifyweb/settings/__init__.py index 990c5ed8ce0..cb04dbccf38 100644 --- a/specifyweb/settings/__init__.py +++ b/specifyweb/settings/__init__.py @@ -83,6 +83,12 @@ } DB_ALIAS = os.getenv("DJANGO_DB_ALIAS", "default") # Might want to set to "app" in the future +if DB_ALIAS not in DATABASES: + valid_aliases = ", ".join(sorted(DATABASES)) + raise ValueError( + f"Invalid DJANGO_DB_ALIAS '{DB_ALIAS}'. " + f"Expected one of: {valid_aliases}." + ) if DB_ALIAS != "default": from copy import deepcopy DATABASES['default'] = deepcopy(DATABASES[DB_ALIAS]) diff --git a/specifyweb/settings/specify_settings.py b/specifyweb/settings/specify_settings.py index 127874067f4..f780068ea0f 100644 --- a/specifyweb/settings/specify_settings.py +++ b/specifyweb/settings/specify_settings.py @@ -55,21 +55,15 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-OPTIONS DATABASE_OPTIONS = {} -# The master user login. This is the MySQL user used to connect to the -# database. This can be the same as the Specify 6 master user. -MASTER_NAME = 'MasterUser' -MASTER_PASSWORD = 'MasterPassword' -MIGRATOR_NAME = 'MasterUser' -MIGRATOR_PASSWORD = 'MasterPassword' -APP_USER_NAME = 'MasterUser' -APP_USER_PASSWORD = 'MasterPassword' - -# MASTER_NAME = os.environ.get('MASTER_NAME', 'root') -# MASTER_PASSWORD = os.environ.get('MASTER_NAME', 'password') -# MIGRATOR_NAME = os.environ.get('MIGRATOR_NAME', MASTER_NAME) -# MIGRATOR_PASSWORD = os.environ.get('MIGRATOR_PASSWORD', MASTER_PASSWORD) -# APP_USER_NAME = os.environ.get('APP_USER_NAME', MIGRATOR_NAME) -# APP_USER_PASSWORD = os.environ.get('APP_USER_PASSWORD', MIGRATOR_PASSWORD) +# The master, migrator, and application user logins. The migrator and app +# credentials fall back through the more privileged roles for compatibility. +ROOT_PASSWORD = os.getenv('MYSQL_ROOT_PASSWORD', 'password') +MASTER_NAME = os.getenv('MASTER_NAME', 'root') +MASTER_PASSWORD = os.getenv('MASTER_PASSWORD', ROOT_PASSWORD) +MIGRATOR_NAME = os.getenv('MIGRATOR_NAME', MASTER_NAME) +MIGRATOR_PASSWORD = os.getenv('MIGRATOR_PASSWORD', MASTER_PASSWORD) +APP_USER_NAME = os.getenv('APP_USER_NAME', MIGRATOR_NAME) +APP_USER_PASSWORD = os.getenv('APP_USER_PASSWORD', MIGRATOR_PASSWORD) # The Specify web attachment server URL. WEB_ATTACHMENT_URL = None diff --git a/specifyweb/specify/management/commands/base_specify_migration.py b/specifyweb/specify/management/commands/base_specify_migration.py index 7d8d57c176f..d1a05eb6d7f 100644 --- a/specifyweb/specify/management/commands/base_specify_migration.py +++ b/specifyweb/specify/management/commands/base_specify_migration.py @@ -25,28 +25,20 @@ def add_arguments(self, parser): def handle(self, *args, **options): use_override = bool(options.get('use_override', False)) alias = options["database"] - conn = connections[alias] logger.info(f"Running base_specify_migration using database alias '{alias}'") + # Validate the alias exists and is usable; fallback to 'master' if not + original_alias = alias try: - transaction.atomic(using=alias) - except: + connections[alias].ensure_connection() + except Exception as e: alias = 'master' + logger.warning(f"Database alias '{original_alias}' unavailable ({e}), falling back to 'master'") + conn = connections[alias] with transaction.atomic(using=alias): with conn.cursor() as cursor: - # Check django table - try: - cursor.execute(""" - SELECT 1 - FROM django_migrations - LIMIT 1; - """) - exists = True - except: - exists = False - - if not exists: + if "django_migrations" not in conn.introspection.table_names(cursor): # Check if the django_migrations table exists and create it if it doesn't cursor.execute(""" CREATE TABLE IF NOT EXISTS `django_migrations` (