Add the ability to gate access to a reservation by QOS

Changelog: Add the ability to gate access to a reservation by QOS.
Issue: 50466
Ticket: 23173
diff --git a/contribs/slurm_completion_help/slurm_completion.sh b/contribs/slurm_completion_help/slurm_completion.sh
index 073760e..f66617d 100644
--- a/contribs/slurm_completion_help/slurm_completion.sh
+++ b/contribs/slurm_completion_help/slurm_completion.sh
@@ -4321,6 +4321,7 @@
 		"nodecnt="
 		"nodes="
 		"partition="
+		"qos="
 		"reservation"      # meta
 		"reservationname=" # meta
 		"skip"
@@ -4364,6 +4365,7 @@
 	license?(s)) __slurm_compreply_list "$(__slurm_licenses)" ;;
 	node?(s)) __slurm_compreply_list "$(__slurm_nodes)" "ALL" "true" ;;
 	partition?(s)) __slurm_compreply "$(__slurm_partitions)" ;;
+	qos?(s)) __slurm_compreply "$(__slurm_qos)" ;;
 	reservationname?(s)) __slurm_compreply "$(__slurm_reservations)" ;;
 	user?(s)) __slurm_compreply_list "$(__slurm_users)" ;;
 	*)
diff --git a/doc/html/reservations.shtml b/doc/html/reservations.shtml
index b7a29e4..3d84a99 100644
--- a/doc/html/reservations.shtml
+++ b/doc/html/reservations.shtml
@@ -3,7 +3,7 @@
 <h1>Advanced Resource Reservation Guide</h1>
 
 <p>Slurm has the ability to reserve resources for jobs
-being executed by select users and/or select accounts.
+being executed by select users and/or QOS and/or select accounts.
 A resource reservation identifies the resources in that reservation
 and a time period during which the reservation is available.
 The resources which can be reserved include cores, nodes, licenses and/or
@@ -173,8 +173,9 @@
   Nodes=tux8,tux9 CoreCnt=2,4 User=brenda
 </pre>
 
-<p>Reservations can not only be created for the use of specific accounts and
-users, but specific accounts and/or users can be prevented from using them.
+<p>Reservations can not only be created for the use of specific accounts and/or
+QOS and/or users, but specific accounts and/or QOS and/or users can be prevented
+from using them.
 In the following example, a reservation is created for account "foo", but user
 "alan" is prevented from using the reservation even when using the account
 "foo".</p>
@@ -529,6 +530,6 @@
 This will prevent the initiation of some jobs which would complete execution
 before a reservation given fewer jobs to time-slice with.</p>
 
-<p style="text-align: center;">Last modified 02 August 2024</p>
+<p style="text-align: center;">Last modified 13 June 2025</p>
 
 <!--#include virtual="footer.txt"-->
diff --git a/doc/man/man1/scontrol.1 b/doc/man/man1/scontrol.1
index a3d463f..d07a15d 100644
--- a/doc/man/man1/scontrol.1
+++ b/doc/man/man1/scontrol.1
@@ -2448,7 +2448,8 @@
 List of accounts permitted to use the reserved nodes, for example
 "Accounts=physcode1,physcode2".
 A user in any of the specified accounts or subaccounts may use the reserved
-nodes. A new reservation must specify Users or Groups and/or Accounts.
+nodes. A new reservation must specify Users or Groups and/or QOS and/or
+Accounts.
 If both Users/Groups and Accounts are specified, a job must match both in order
 to use the reservation.
 Accounts can also be denied access to reservations by preceding all of the
@@ -2796,7 +2797,7 @@
 \fBGroups\fR=<\fIgroup list\fR>
 List of groups permitted to use the reserved nodes, for example
 "Group=bio,chem".
-A new reservation must specify Users or Groups and/or Accounts.
+A new reservation must specify Users or Groups and/or QOS and/or Accounts.
 If both Users/Groups and Accounts are specified, a job must match both in order
 to use the reservation.
 Unlike users groups do not allow denied access to reservations.
@@ -2816,10 +2817,32 @@
 .IP
 
 .TP
+\fBQOS\fR=<\fIqos list\fR>
+List of QOS permitted to use the reserved resources, for example
+"QOS=normal,standby".
+A user using any of the specified QOS may use the reserved
+resources. A new reservation must specify Users or Groups and/or QOS and/or
+Accounts.
+If both Users/Groups and QOS are specified, a job must match both in order
+to use the reservation.
+QOS can also be denied access to reservations by preceding all of the
+QOS names with '\-'. Alternately precede the equal sign with '\-'.
+For example, "QOS=\-normal,\-standby" or "QOS\-=normal,standby"
+will permit any QOS except normal and standby to use the reservation.
+You can add or remove individual QOS from an existing reservation by
+using the update command and adding a '+' or '\-' sign before the '=' sign.
+If QOS are denied access to a reservation (QOS name preceded by a '\-'),
+then all other QOS are implicitly allowed to use the reservation and it is
+not possible to also explicitly specify allowed QOS.
+Root and the SlurmUser are given access to all reservations, regardless of the
+QOS set here.
+.IP
+
+.TP
 \fBUsers\fR=<\fIuser list\fR>
 List of users permitted to use the reserved nodes, for example
 "User=jones1,smith2".
-A new reservation must specify Users or Groups and/or Accounts.
+A new reservation must specify Users or Groups and/or QOS and/or Accounts.
 If both Users/Groups and Accounts are specified, a job must match both in order
 to use the reservation.
 Users can also be denied access to reservations by preceding all of the
diff --git a/src/api/reservation_info.c b/src/api/reservation_info.c
index a1e33d9..7f1ad0d 100644
--- a/src/api/reservation_info.c
+++ b/src/api/reservation_info.c
@@ -148,6 +148,11 @@
 	xstrcat(out, line_end);
 
 	/****** Line ******/
+	xstrfmtcat(out,
+		   "QOS=%s", resv_ptr->qos);
+	xstrcat(out, line_end);
+
+	/****** Line ******/
 	if ((resv_ptr->start_time <= now) && (resv_ptr->end_time >= now))
 		state = "ACTIVE";
 	xstrfmtcat(out,
diff --git a/src/common/slurm_protocol_defs.c b/src/common/slurm_protocol_defs.c
index c7632fc..06d255e 100644
--- a/src/common/slurm_protocol_defs.c
+++ b/src/common/slurm_protocol_defs.c
@@ -6490,8 +6490,9 @@
 
 	if (((resv_msg->users == NULL) || (resv_msg->users[0] == '\0')) &&
 	    ((resv_msg->groups == NULL) || (resv_msg->groups[0] == '\0')) &&
+	    (!resv_msg->qos || (resv_msg->qos[0] == '\0')) &&
 	    ((resv_msg->accounts == NULL) || (resv_msg->accounts[0] == '\0'))) {
-		*err_msg = "Either Users/Groups and/or Accounts must be specified.  No reservation created.";
+		*err_msg = "Either Users/Groups, QOS and/or Accounts must be specified.  No reservation created.";
 		return SLURM_ERROR;
 	} else if (resv_msg->users && resv_msg->groups) {
 		*err_msg = "Users and Groups are mutually exclusive.  You can have one or the other, but not both.  No reservation created.";
diff --git a/src/scontrol/create_res.c b/src/scontrol/create_res.c
index d7db29c..044a180 100644
--- a/src/scontrol/create_res.c
+++ b/src/scontrol/create_res.c
@@ -167,6 +167,22 @@
 				resv_msg_ptr->users = val;
 			}
 
+		} else if (!xstrncasecmp(tag, "QOS", MAX(taglen, 1))) {
+			if (resv_msg_ptr->qos) {
+				exit_code = 1;
+				error("Parameter %s specified more than once",
+				      argv[i]);
+				return SLURM_ERROR;
+			}
+			if (plus_minus) {
+				resv_msg_ptr->qos =
+					scontrol_process_plus_minus(plus_minus,
+								    val, false);
+				*res_free_flags |= RESV_FREE_STR_QOS;
+				plus_minus = '\0';
+			} else {
+				resv_msg_ptr->qos = val;
+			}
 		} else if (!xstrncasecmp(tag, "ReservationName",
 			   MAX(taglen, 1))) {
 			resv_msg_ptr->name = val;
diff --git a/src/slurmctld/job_scheduler.c b/src/slurmctld/job_scheduler.c
index 342e63e..a6002d5 100644
--- a/src/slurmctld/job_scheduler.c
+++ b/src/slurmctld/job_scheduler.c
@@ -4318,6 +4318,8 @@
 		FREE_NULL_BITMAP(avail_bitmap);
 		reservation_delete_resv_exc_parts(&resv_exc);
 		job_start_data->rc = rc2;
+		if (rc2 == ESLURM_INVALID_QOS)
+			return 0;
 		return -1;
 	}
 
diff --git a/src/slurmctld/reservation.c b/src/slurmctld/reservation.c
index a6e6125..a91a937 100644
--- a/src/slurmctld/reservation.c
+++ b/src/slurmctld/reservation.c
@@ -1000,13 +1000,13 @@
 	else
 		duration = resv_ptr->duration;
 
-	info("%s: Name=%s StartTime=%s EndTime=%s Duration=%d Flags=%s NodeCnt=%u CoreCnt=%u NodeList=%s Features=%s PartitionName=%s Users=%s Groups=%s Accounts=%s Licenses=%s BurstBuffer=%s TRES=%s Comment=%s",
+	info("%s: Name=%s StartTime=%s EndTime=%s Duration=%d Flags=%s NodeCnt=%u CoreCnt=%u NodeList=%s Features=%s PartitionName=%s Users=%s Groups=%s Accounts=%s Licenses=%s QOS=%s BurstBuffer=%s TRES=%s Comment=%s",
 	     mode, resv_ptr->name, start_str, end_str, duration,
 	     flag_str, resv_ptr->node_cnt, resv_ptr->core_cnt,
 	     resv_ptr->node_list,
 	     resv_ptr->features, resv_ptr->partition,
 	     resv_ptr->users, resv_ptr->groups, resv_ptr->accounts,
-	     resv_ptr->licenses,
+	     resv_ptr->licenses, resv_ptr->qos,
 	     resv_ptr->burst_buffer, resv_ptr->tres_str,
 	     resv_ptr->comment);
 
@@ -1260,7 +1260,8 @@
 				goto end_it;
 			}
 		}
-	} else if (accounting_enforce & ACCOUNTING_ENFORCE_ASSOCS) {
+	} else if (!resv_ptr->qos &&
+		   (accounting_enforce & ACCOUNTING_ENFORCE_ASSOCS)) {
 		error("We need at least 1 user or 1 account to "
 		      "create a reservtion.");
 		rc = SLURM_ERROR;
@@ -1293,6 +1294,266 @@
 	return rc;
 }
 
+static void _addto_name_str(foreach_set_allow_str_t *set_allow_str,
+			    char *name)
+{
+	xstrfmtcatat(*set_allow_str->str, &set_allow_str->str_pos, "%s%s%s",
+		     *set_allow_str->str ? "," : "",
+		     set_allow_str->prefix,
+		     name);
+}
+
+static int _foreach_set_qos_name_str(void *x, void *arg)
+{
+	slurmdb_qos_rec_t *qos_ptr = x;
+	foreach_set_allow_str_t *set_allow_str = arg;
+
+	_addto_name_str(set_allow_str, qos_ptr->name);
+	return 0;
+}
+
+extern int _sort_qos_list_asc(void *v1, void *v2)
+{
+	slurmdb_qos_rec_t *qos_a = *(slurmdb_qos_rec_t **) v1;
+	slurmdb_qos_rec_t *qos_b = *(slurmdb_qos_rec_t **) v2;
+
+	return slurm_sort_char_list_asc(&qos_a->name, &qos_b->name);
+}
+
+/*
+ * Since the returned qos_list is full of pointers from the
+ * assoc_mgr_qos_list assoc_mgr_lock_t READ_LOCK on
+ * qos must be set before calling this function and while
+ * handling it after a return.
+ */
+static int _append_to_qos_list(list_t *qos_list, char *qos_name)
+{
+	int rc = ESLURM_INVALID_QOS;
+	slurmdb_qos_rec_t *qos_ptr = NULL;
+	slurmdb_qos_rec_t qos = {
+		.name = qos_name,
+	};
+
+	xassert(qos_list);
+	xassert(qos.name);
+	xassert(verify_assoc_lock(QOS_LOCK, READ_LOCK));
+
+	if (assoc_mgr_fill_in_qos(acct_db_conn, &qos, accounting_enforce,
+				  &qos_ptr, true)) {
+		if (accounting_enforce & ACCOUNTING_ENFORCE_QOS) {
+			error("No QOS by name %s", qos.name);
+		} else {
+			verbose("No QOS by name %s", qos.name);
+			rc = SLURM_SUCCESS;
+		}
+	}
+
+	if (qos_ptr) {
+		if (!list_find_first(qos_list, slurm_find_ptr_in_list, qos_ptr))
+			list_append(qos_list, qos_ptr);
+		rc = SLURM_SUCCESS;
+	}
+
+	return rc;
+}
+
+/*
+ * Since the returned qos_list is full of pointers from the
+ * assoc_mgr_qos_list assoc_mgr_lock_t READ_LOCK on
+ * qos must be set before calling this function and while
+ * handling it after a return.
+ */
+static int _remove_from_qos_list(list_t *qos_list, char *qos_name)
+{
+	int rc = ESLURM_INVALID_QOS;
+	slurmdb_qos_rec_t *qos_ptr = NULL;
+	slurmdb_qos_rec_t qos = {
+		.name = qos_name,
+	};
+
+	xassert(qos_list);
+	xassert(qos.name);
+	xassert(verify_assoc_lock(QOS_LOCK, READ_LOCK));
+
+	if (assoc_mgr_fill_in_qos(acct_db_conn, &qos, accounting_enforce,
+				  &qos_ptr, true)) {
+		if (accounting_enforce & ACCOUNTING_ENFORCE_QOS) {
+			error("No QOS by name %s", qos.name);
+		} else {
+			verbose("No QOS by name %s", qos.name);
+			rc = SLURM_SUCCESS;
+		}
+	}
+
+	if (qos_ptr) {
+		(void) list_delete_first(qos_list, slurm_find_ptr_in_list,
+					 qos_ptr);
+		rc = SLURM_SUCCESS;
+	}
+
+	return rc;
+}
+
+/*
+ * Validate a comma delimited list of account names and build an array of
+ *	them
+ * IN account       - a list of account names
+ * OUT account_cnt  - number of accounts in the list
+ * OUT account_list - list of the account names,
+ *		      CALLER MUST XFREE this plus each individual record
+ * OUT account_not  - true of account_list is that of accounts to be blocked
+ *                    from reservation access
+ * RETURN 0 on success
+ */
+static int _build_qos_list(char *qos, list_t **qos_list, bool *qos_not,
+			   bool break_on_failure)
+{
+	char *last = NULL, *tmp, *tok;
+	int rc = SLURM_SUCCESS;
+	assoc_mgr_lock_t locks = {
+		.qos = READ_LOCK,
+	};
+
+	xassert(qos_list);
+
+	*qos_not = false;
+
+	if (!qos)
+		return ESLURM_INVALID_QOS;
+
+	assoc_mgr_lock(&locks);
+
+	tmp = xstrdup(qos);
+	tok = strtok_r(tmp, ",", &last);
+	while (tok) {
+		if (tok[0] == '-') {
+			if (!*qos_list) {
+				*qos_not = true;
+			} else if (*qos_not != true) {
+				info("Reservation request has some not/qos");
+				rc = ESLURM_INVALID_QOS;
+				break;
+			}
+			tok++;
+		} else if (*qos_not != false) {
+			info("Reservation request has some not/qos");
+			rc = ESLURM_INVALID_QOS;
+			break;
+		}
+
+		if (!*qos_list)
+			*qos_list = list_create(NULL);
+		if (((rc = _append_to_qos_list(*qos_list, tok)) !=
+		     SLURM_SUCCESS) && break_on_failure)
+			break;
+
+		tok = strtok_r(NULL, ",", &last);
+	}
+
+	if (rc != SLURM_SUCCESS)
+		FREE_NULL_LIST(*qos_list);
+
+	if (*qos_list) {
+		list_sort(*qos_list, _sort_qos_list_asc);
+	}
+
+	assoc_mgr_unlock(&locks);
+
+	xfree(tmp);
+
+	return rc;
+}
+
+/*
+ * Update a qos list for an existing reservation based upon an
+ *	update comma delimited specification of qos to add (+name),
+ *	remove (-name), or set value of
+ * IN/OUT resv_ptr - pointer to reservation structure being updated
+ * IN qos     - a list of qos names, to set, add, or remove
+ * RETURN 0 on success
+ */
+static int _update_qos_list(slurmctld_resv_t *resv_ptr, char *qos)
+{
+	int rc = SLURM_SUCCESS;
+	char *last = NULL, *tmp, *tok;
+	bool minus_qos = false, cleared = false;
+	list_t *qos_list = NULL;
+	assoc_mgr_lock_t locks = {
+		.qos = READ_LOCK,
+	};
+
+	if (!qos)
+		return ESLURM_INVALID_QOS;
+
+	if (!resv_ptr->qos_list)
+		resv_ptr->qos_list = list_create(NULL);
+	qos_list = list_shallow_copy(resv_ptr->qos_list);
+
+	assoc_mgr_lock(&locks);
+
+	tmp = xstrdup(qos);
+	tok = strtok_r(tmp, ",", &last);
+	while (tok) {
+		if (tok[0] == '-') {
+			minus_qos = 1;
+			tok++;
+		} else if (tok[0] == '+') {
+			minus_qos = 0;
+			tok++;
+		} else if (tok[0] == '\0') {
+			continue;
+		} else {
+			/* The request is a completely new list */
+			if (!cleared) {
+				list_flush(qos_list);
+				resv_ptr->ctld_flags &= (~RESV_CTLD_QOS_NOT);
+				cleared = true;
+			}
+		}
+
+		if (resv_ptr->ctld_flags & RESV_CTLD_QOS_NOT) {
+			if (minus_qos)
+				rc = _append_to_qos_list(qos_list, tok);
+			else
+				rc = _remove_from_qos_list(qos_list, tok);
+		} else {
+			if (minus_qos)
+				rc = _remove_from_qos_list(qos_list, tok);
+			else
+				rc = _append_to_qos_list(qos_list, tok);
+		}
+
+		if (rc != SLURM_SUCCESS)
+			break;
+
+		tok = strtok_r(NULL, ",", &last);
+	}
+
+	if ((rc == SLURM_SUCCESS) && list_count(qos_list)) {
+		foreach_set_allow_str_t set_allow_str = {
+			.prefix = (resv_ptr->ctld_flags & RESV_CTLD_QOS_NOT) ?
+			"-" : "",
+			.str = &resv_ptr->qos,
+		};
+
+		list_sort(qos_list, _sort_qos_list_asc);
+		xfree(resv_ptr->qos);
+		(void) list_for_each(qos_list, _foreach_set_qos_name_str,
+				     &set_allow_str);
+		FREE_NULL_LIST(resv_ptr->qos_list);
+		resv_ptr->qos_list = qos_list;
+		qos_list = NULL;
+	} else if (rc == SLURM_SUCCESS)
+		rc = ESLURM_INVALID_QOS;
+
+	assoc_mgr_unlock(&locks);
+
+	FREE_NULL_LIST(qos_list);
+	xfree(tmp);
+
+	return rc;
+}
+
 static int _set_access(slurmctld_resv_t *resv_ptr)
 {
 	int rc = SLURM_SUCCESS;
@@ -2807,6 +3068,9 @@
 		if (resv_ptr->node_list)
 			xstrfmtcatat(tmp_str, &tmp_str_pos, " nodes=%s",
 				     resv_ptr->node_list);
+		if (resv_ptr->qos)
+			xstrfmtcatat(tmp_str, &tmp_str_pos, " qos=%s",
+				     resv_ptr->qos);
 		if (resv_ptr->tres_fmt_str)
 			xstrfmtcatat(tmp_str, &tmp_str_pos, " tres=%s",
 				     resv_ptr->tres_fmt_str);
@@ -3006,8 +3270,9 @@
 	uid_t *user_list = NULL;
 	list_t *license_list = NULL;
 	uint32_t total_node_cnt = 0;
-	bool account_not = false, user_not = false;
+	bool account_not = false, user_not = false, qos_not = false;
 	resv_select_t resv_select = { 0 };
+	list_t *qos_list = NULL;
 
 	_create_resv_lists(false);
 
@@ -3161,9 +3426,10 @@
 		goto bad_parse;
 
 	} else if (!resv_desc_ptr->accounts &&
-	    !resv_desc_ptr->users &&
-	    !resv_desc_ptr->groups) {
-		info("Reservation request lacks users, accounts or groups");
+		   !resv_desc_ptr->users &&
+		   !resv_desc_ptr->qos &&
+		   !resv_desc_ptr->groups) {
+		info("Reservation request lacks users, accounts, QOS, or groups");
 		rc = ESLURM_RESERVATION_EMPTY;
 		goto bad_parse;
 	}
@@ -3192,6 +3458,22 @@
 		}
 	}
 
+	if (resv_desc_ptr->qos) {
+		foreach_set_allow_str_t set_allow_str = {
+			.str = &resv_desc_ptr->qos,
+		};
+
+		rc = _build_qos_list(resv_desc_ptr->qos, &qos_list,
+				     &qos_not, true);
+		if (rc != SLURM_SUCCESS)
+			goto bad_parse;
+
+		set_allow_str.prefix = qos_not ? "-" : "";
+		xfree(resv_desc_ptr->qos);
+		(void) list_for_each(qos_list, _foreach_set_qos_name_str,
+				     &set_allow_str);
+	}
+
 	if (resv_desc_ptr->licenses) {
 		bool valid = true;
 		license_list = _license_validate2(resv_desc_ptr, &valid);
@@ -3400,6 +3682,8 @@
 		resv_ptr->ctld_flags |= RESV_CTLD_USER_NOT;
 	if (account_not)
 		resv_ptr->ctld_flags |= RESV_CTLD_ACCT_NOT;
+	if (qos_not)
+		resv_ptr->ctld_flags |= RESV_CTLD_QOS_NOT;
 
 	resv_ptr->duration      = resv_desc_ptr->duration;
 	if (resv_desc_ptr->purge_comp_time != NO_VAL)
@@ -3447,6 +3731,10 @@
 	resv_ptr->user_cnt	= user_cnt;
 	resv_ptr->user_list	= user_list;
 	user_list = NULL;
+	resv_ptr->qos = resv_desc_ptr->qos;
+	resv_desc_ptr->qos = NULL;
+	resv_ptr->qos_list = qos_list;
+	qos_list = NULL;
 
 	if (!(resv_desc_ptr->flags & RESERVE_FLAG_GRES_REQ) &&
 	    (resv_desc_ptr->core_cnt == NO_VAL)) {
@@ -3482,6 +3770,7 @@
 		xfree(account_list[i]);
 	xfree(account_list);
 	FREE_NULL_LIST(license_list);
+	FREE_NULL_LIST(qos_list);
 	_free_resv_select_members(&resv_select);
 	xfree(user_list);
 	return rc;
@@ -3895,10 +4184,19 @@
 		goto update_failure;
 	}
 
+	if (resv_desc_ptr->qos) {
+		rc = _update_qos_list(resv_ptr, resv_desc_ptr->qos);
+		if (rc) {
+			error_code = rc;
+			goto update_failure;
+		}
+	}
+
 	if (!resv_ptr->users &&
 	    !resv_ptr->accounts &&
+	    !resv_ptr->qos &&
 	    !resv_ptr->groups) {
-		info("Reservation %s request lacks users, accounts or groups",
+		info("Reservation %s request lacks users, accounts, QOS or groups",
 		     resv_desc_ptr->name);
 		error_code = ESLURM_RESERVATION_EMPTY;
 		goto update_failure;
@@ -4490,6 +4788,19 @@
 		}
 	}
 
+	if (resv_ptr->qos) {
+		bool qos_not; /* we don't care about this */
+		(void) _build_qos_list(resv_ptr->qos,
+				       &resv_ptr->qos_list,
+				       &qos_not, false);
+
+		if (!resv_ptr->qos_list || !list_count(resv_ptr->qos_list)) {
+			error("Reservation %s has invalid QOS (%s)",
+			      resv_ptr->name, resv_ptr->qos);
+			return false;
+		}
+	}
+
 	if ((resv_ptr->flags & RESERVE_FLAG_PART_NODES) &&
 	    resv_ptr->part_ptr && resv_ptr->part_ptr->node_bitmap) {
 		memset(&old_resv_ptr, 0, sizeof(slurmctld_resv_t));
@@ -6413,6 +6724,9 @@
 		char tmp_char[30];
 		slurmdb_assoc_rec_t *assoc;
 		if (!resv_ptr->assoc_list) {
+			if (resv_ptr->qos_list)
+				return SLURM_SUCCESS;
+
 			error("Reservation %s has no association list. "
 			      "Checking user/account lists",
 			      resv_ptr->name);
@@ -6513,6 +6827,67 @@
 }
 
 /*
+ * Check if user is requesting a QOS that isn't
+ * allowed in the reservation.
+ * RET SLURM_SUCCESS if true, some error code otherwise
+ */
+static int _valid_job_access_resv_at_sched(job_record_t *job_ptr,
+					   slurmctld_resv_t *resv_ptr)
+{
+	int rc = SLURM_SUCCESS;
+
+	if (validate_slurm_user(job_ptr->user_id))
+		return SLURM_SUCCESS;
+
+	/* Check QOS */
+	if (resv_ptr->qos_list) {
+		slurmdb_qos_rec_t *qos_ptr = NULL;
+		if (!job_ptr->qos_ptr) {
+			slurmdb_qos_rec_t qos_rec = {
+				.id = job_ptr->qos_id,
+			};
+			/*
+			 * This should never be called, but just to be
+			 * safe we will try to fill it in.
+			 */
+			if (assoc_mgr_fill_in_qos(
+				    acct_db_conn, &qos_rec,
+				    accounting_enforce,
+				    &job_ptr->qos_ptr, false) ||
+			    !job_ptr->qos_ptr) {
+				return ESLURM_INVALID_QOS;
+			}
+		}
+
+		/*
+		 * Since we do not allow mixed state check the list's pointers.
+		 */
+		qos_ptr = list_find_first(resv_ptr->qos_list,
+					  slurm_find_ptr_in_list,
+					  job_ptr->qos_ptr);
+
+		if (resv_ptr->ctld_flags & RESV_CTLD_QOS_NOT) {
+			if (qos_ptr) { /* explicitly denied */
+				rc = ESLURM_INVALID_QOS;
+			}
+		} else if (!qos_ptr) { /* not allowed */
+			rc = ESLURM_INVALID_QOS;
+		}
+
+		if (rc != SLURM_SUCCESS) {
+			debug2("%pJ attempted to use reservation '%s' with QOS '%s' not allowed in reservation (%s)",
+			       job_ptr,
+			       resv_ptr->name,
+			       job_ptr->qos_ptr->name,
+			       resv_ptr->qos);
+			return rc;
+		}
+	}
+
+	return rc;
+}
+
+/*
  * Determine if a job can start now based only upon reservations
  *
  * IN job_ptr      - job to test
@@ -6927,6 +7302,11 @@
 		rc2 = _valid_job_access_resv(job_ptr, resv_ptr, true);
 		if (rc2 != SLURM_SUCCESS)
 			return rc2;
+
+		rc2 = _valid_job_access_resv_at_sched(job_ptr, resv_ptr);
+		if (rc2 != SLURM_SUCCESS)
+			return rc2;
+
 		/*
 		 * Just in case the reservation was altered since last looking
 		 * we want to make sure things are good in the database.
diff --git a/testsuite/README b/testsuite/README
index 113a79a..414611d 100644
--- a/testsuite/README
+++ b/testsuite/README
@@ -955,6 +955,7 @@
 test_123_2   Test RESV_DEL_HOLD
 test_123_3   Test overlapping reservations.
 test_123_4   Test reservation access
+test_123_5   Test reservation access based on QOS
 
 test_124_#   Testing of AccountingStorageEnforce.
 =================================================
diff --git a/testsuite/python/tests/test_123_5.py b/testsuite/python/tests/test_123_5.py
new file mode 100644
index 0000000..7f85081
--- /dev/null
+++ b/testsuite/python/tests/test_123_5.py
@@ -0,0 +1,103 @@
+############################################################################
+# Copyright (C) SchedMD LLC.
+############################################################################
+import os
+import pytest
+import atf
+
+test_name = os.path.splitext(os.path.basename(__file__))[0]
+qos1 = f"{test_name}_qos"
+acct1 = f"{test_name}_acct"
+res_name = f"{test_name}_resv"
+testuser = atf.properties["test-user"]
+
+
+@pytest.fixture(scope="module", autouse=True)
+def setup():
+    global local_cluster_name
+
+    atf.require_accounting()
+    atf.require_slurm_running()
+    atf.require_config_parameter_includes("AccountingStorageEnforce", "qos")
+    local_cluster_name = atf.get_config_parameter("ClusterName")
+
+    # Create test QOS and User that can use it
+    atf.run_command(
+        f"sacctmgr -i add qos {qos1}",
+        user=atf.properties["slurm-user"],
+    )
+    atf.run_command(
+        f"sacctmgr -i add account {acct1} cluster={local_cluster_name}",
+        user=atf.properties["slurm-user"],
+    )
+    atf.run_command(
+        f"sacctmgr -i add user {testuser} cluster={local_cluster_name} account={acct1} qos=normal,{qos1}",
+        user=atf.properties["slurm-user"],
+    )
+
+    atf.run_command(
+        f"sacctmgr -i add user {testuser} cluster={local_cluster_name} wckey={acct1}",
+        user=atf.properties["slurm-user"],
+    )
+    # Create the reservation for QOS test
+    result = atf.run_command(
+        f"scontrol create reservationname={res_name} qos={qos1} start=now duration=1 nodecnt=1",
+        user=atf.properties["slurm-user"],
+    )
+    assert result["exit_code"] == 0, "Couldn't create the reservation!"
+
+    yield
+
+    atf.run_command(
+        f"scontrol delete reservation {res_name}",
+        user=atf.properties["slurm-user"],
+        quiet=True,
+    )
+    atf.run_command(
+        f"sacctmgr -i remove user {testuser} wckey={acct1}",
+        user=atf.properties["slurm-user"],
+        quiet=True,
+    )
+    atf.run_command(
+        f"sacctmgr -i remove user {testuser} account={acct1}",
+        user=atf.properties["slurm-user"],
+        quiet=True,
+    )
+    atf.run_command(
+        f"sacctmgr -i remove account {acct1}",
+        user=atf.properties["slurm-user"],
+        quiet=True,
+    )
+    atf.run_command(
+        f"sacctmgr -i remove qos {qos1}",
+        user=atf.properties["slurm-user"],
+        quiet=True,
+    )
+
+
+def test_reservation_qos():
+    """Test that a reservation created for QOS {qos1} can't be used by atf"""
+
+    # Try to run a job as in the wrong QOS
+    result = atf.run_command(
+        f"srun -N1 --reservation={res_name} --account={acct1} --qos=normal true",
+        user=atf.properties["test-user"],
+    )
+    assert (
+        result["exit_code"] != 0
+    ), "The job should have been denied! {result[exit_code]}"
+    assert (
+        "Problem using reservation" in result["stderr"]
+    ), "The job should have been denied!"
+
+    # Try to run a job as in the correct QOS
+    result = atf.run_command(
+        f"srun -N1 --reservation={res_name} --account={acct1} --qos=normal,{qos1} true",
+        user=atf.properties["test-user"],
+    )
+    assert (
+        result["exit_code"] == 0
+    ), "ExitCode wasn't 0. The job should not have been denied!"
+    assert (
+        "Problem using reservation" not in result["stderr"]
+    ), "The job should not have been denied for user!"