This is an automated email from the ASF dual-hosted git repository.
brbzull0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 4b2ee8608b mgmt/rpc: refuse RECA_READ_ONLY/RECA_NO_ACCESS writes
(#13142)
4b2ee8608b is described below
commit 4b2ee8608ba54105bde19aa660e77d70c9ab83a9
Author: Damian Meden <[email protected]>
AuthorDate: Wed May 13 10:18:28 2026 +0200
mgmt/rpc: refuse RECA_READ_ONLY/RECA_NO_ACCESS writes (#13142)
* mgmt/rpc: refuse RECA_READ_ONLY/RECA_NO_ACCESS writes
set_config_records did not consult the registrant's access tier,
so admin_config_set_records (and "traffic_ctl config set") accepted
writes to records the registrant marked as protected. Read the
record's access_type alongside the existing metadata, and refuse
the write with an specific error code (RECORD_READ_ONLY /
RECORD_NO_ACCESS)
---
src/mgmt/rpc/handlers/common/RecordsUtils.cc | 4 +
src/mgmt/rpc/handlers/common/RecordsUtils.h | 4 +-
src/mgmt/rpc/handlers/config/Configuration.cc | 41 +++++++-
.../traffic_ctl/traffic_ctl_set_read_only.test.py | 113 +++++++++++++++++++++
4 files changed, 156 insertions(+), 6 deletions(-)
diff --git a/src/mgmt/rpc/handlers/common/RecordsUtils.cc
b/src/mgmt/rpc/handlers/common/RecordsUtils.cc
index 0d2291c0b4..fb8811f861 100644
--- a/src/mgmt/rpc/handlers/common/RecordsUtils.cc
+++ b/src/mgmt/rpc/handlers/common/RecordsUtils.cc
@@ -63,6 +63,10 @@ RPCRecordErrorCategory::message(int ev) const
return {"Found record does not match the requested type"};
case rpc::handlers::errors::RecordError::INVALID_INCOMING_DATA:
return {"Invalid request data provided"};
+ case rpc::handlers::errors::RecordError::RECORD_READ_ONLY:
+ return {"Record is read-only and cannot be modified through the management
interface."};
+ case rpc::handlers::errors::RecordError::RECORD_NO_ACCESS:
+ return {"Record is not accessible through the management interface."};
default:
return "Record error error " + std::to_string(ev);
}
diff --git a/src/mgmt/rpc/handlers/common/RecordsUtils.h
b/src/mgmt/rpc/handlers/common/RecordsUtils.h
index b94e66dc07..5be131b8d4 100644
--- a/src/mgmt/rpc/handlers/common/RecordsUtils.h
+++ b/src/mgmt/rpc/handlers/common/RecordsUtils.h
@@ -42,7 +42,9 @@ enum class RecordError {
GENERAL_ERROR,
RECORD_WRITE_ERROR,
REQUESTED_TYPE_MISMATCH,
- INVALID_INCOMING_DATA
+ INVALID_INCOMING_DATA,
+ RECORD_READ_ONLY,
+ RECORD_NO_ACCESS
};
std::error_code make_error_code(rpc::handlers::errors::RecordError e);
} // namespace rpc::handlers::errors
diff --git a/src/mgmt/rpc/handlers/config/Configuration.cc
b/src/mgmt/rpc/handlers/config/Configuration.cc
index 04c8716574..9f901bbb62 100644
--- a/src/mgmt/rpc/handlers/config/Configuration.cc
+++ b/src/mgmt/rpc/handlers/config/Configuration.cc
@@ -119,8 +119,12 @@ set_config_records(std::string_view const & /* id
ATS_UNUSED */, YAML::Node cons
{
swoc::Rv<YAML::Node> resp;
- // we need the type and the update type for now.
- using LookupContext = std::tuple<RecDataT, RecCheckT, const char *,
RecUpdateT>;
+ // we need the type and the update type for now, plus the access tier so we
+ // can refuse writes to records the registrant marked as protected, and a
+ // flag tracking whether the lookup actually returned a CONFIG record (the
+ // RPC is for config writes; metric/process/plugin records must be
+ // refused even though RecLookupRecord finds them).
+ using LookupContext = std::tuple<RecDataT, RecCheckT, const char *,
RecUpdateT, RecAccessT, bool>;
for (auto const &kv : params) {
SetRecordCmdInfo info;
@@ -131,20 +135,25 @@ set_config_records(std::string_view const & /* id
ATS_UNUSED */, YAML::Node cons
continue;
}
- LookupContext recordCtx;
+ // Value-initialize the tuple so reads after the lookup are well defined
+ // even when the callback never assigns a field (non-CONFIG record, or
+ // an early failure inside the callback).
+ LookupContext recordCtx{};
// Get record info first. TODO: we may just want to get the full record
and then send it back as a response.
const auto ret = RecLookupRecord(
info.name.c_str(),
[](const RecRecord *record, void *data) {
- auto &[dataType, checkType, pattern, updateType] =
*static_cast<LookupContext *>(data);
+ auto &[dataType, checkType, pattern, updateType, accessType,
is_config] = *static_cast<LookupContext *>(data);
if (REC_TYPE_IS_CONFIG(record->rec_type)) {
+ is_config = true;
dataType = record->data_type;
checkType = record->config_meta.check_type;
if (record->config_meta.check_expr) {
pattern = record->config_meta.check_expr;
}
updateType = record->config_meta.update_type;
+ accessType = record->config_meta.access_type;
}
},
&recordCtx);
@@ -156,7 +165,29 @@ set_config_records(std::string_view const & /* id
ATS_UNUSED */, YAML::Node cons
}
// now set the value.
- auto const &[dataType, checkType, pattern, updateType] = recordCtx;
+ auto const &[dataType, checkType, pattern, updateType, accessType,
is_config] = recordCtx;
+
+ // RecLookupRecord finds metrics, config records, etc.
+ // The set RPC only operates on config records;
+ // refuse anything else with a tier-specific error code so callers can
+ // distinguish "wrong record kind" from "record missing".
+ if (!is_config) {
+ auto const ec = std::error_code{err::RecordError::RECORD_NOT_CONFIG};
+ resp.errata().assign(ec).note("{}", ec);
+ continue;
+ }
+
+ // Honour the registrant's access tier. RECA_READ_ONLY records may only
+ // be changed by in-process code (config file load, environment override,
+ // etc.); RECA_NO_ACCESS records are not exposed to the management plane
+ // at all. Surface the per-tier error code in the JSONRPC error.data
+ // entry so callers can branch on the code instead of parsing messages.
+ if (accessType == RECA_READ_ONLY || accessType == RECA_NO_ACCESS) {
+ auto const ec =
+ std::error_code{accessType == RECA_READ_ONLY ?
err::RecordError::RECORD_READ_ONLY : err::RecordError::RECORD_NO_ACCESS};
+ resp.errata().assign(ec).note("{}", ec);
+ continue;
+ }
// run the check only if we have something to check against it.
if (pattern != nullptr && RecordValidityCheck(info.value.c_str(),
checkType, pattern) == false) {
diff --git a/tests/gold_tests/traffic_ctl/traffic_ctl_set_read_only.test.py
b/tests/gold_tests/traffic_ctl/traffic_ctl_set_read_only.test.py
new file mode 100644
index 0000000000..b03a548d05
--- /dev/null
+++ b/tests/gold_tests/traffic_ctl/traffic_ctl_set_read_only.test.py
@@ -0,0 +1,113 @@
+'''
+Verify that the management RPC refuses to write records registered as
+RECA_READ_ONLY.
+'''
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from jsonrpc import Request, Response
+
+Test.Summary = '''
+Verify that records registered with RECA_READ_ONLY cannot be modified through
+the management RPC backing "traffic_ctl config set"
+(admin_config_set_records).
+'''
+
+Test.ContinueOnFail = True
+
+# proxy.config.thread.max_heartbeat_mseconds is registered as RECA_READ_ONLY
+# in src/records/RecordsConfig.cc with a default of 60. RECA_READ_ONLY = 2
+# in the RecAccessT enum (see include/records/RecDefs.h).
+READ_ONLY_RECORD = "proxy.config.thread.max_heartbeat_mseconds"
+DEFAULT_VALUE = "60"
+ATTEMPTED_VALUE = "999"
+RECA_READ_ONLY = "2"
+RECT_CONFIG_BIT = "1" # bit value in the rec_types filter
+
+# RecordError::RECORD_READ_ONLY in src/mgmt/rpc/handlers/common/RecordsUtils.h
+# (assigned as Codes::RECORD + offset). This is the per-tier code that the
+# JSONRPC response must surface in error.data[*].code so programmatic
+# clients can branch on the access tier without parsing the message text.
+RECORD_READ_ONLY_CODE = 2009
+
+ts = Test.MakeATSProcess("ts")
+
+
+def lookup_request():
+ """Build a JSONRPC request that fetches the read-only record."""
+ return Request.admin_lookup_records([{"record_name": READ_ONLY_RECORD,
"rec_types": [RECT_CONFIG_BIT]}])
+
+
+def assert_record_at_default(resp: Response):
+ """Validate the looked-up record is RECA_READ_ONLY and at its default
value."""
+ if resp.is_error():
+ return (False, f"unexpected error: {resp.error_as_str()}")
+
+ records = resp.result.get('recordList', [])
+ if len(records) != 1:
+ return (False, f"expected exactly 1 record, got {len(records)}")
+
+ rec = records[0]['record']
+ if rec.get('record_name') != READ_ONLY_RECORD:
+ return (False, f"record_name {rec.get('record_name')!r} !=
{READ_ONLY_RECORD!r}")
+ if rec.get('current_value') != DEFAULT_VALUE:
+ return (False, f"current_value {rec.get('current_value')!r} !=
{DEFAULT_VALUE!r}")
+
+ access = rec.get('config_meta', {}).get('access_type')
+ if str(access) != RECA_READ_ONLY:
+ return (False, f"access_type {access!r} != {RECA_READ_ONLY!r} (record
is not RECA_READ_ONLY)")
+
+ return (True, "record is RECA_READ_ONLY and at the registered default")
+
+
+def assert_set_was_rejected(resp: Response):
+ """Validate the set attempt produced the per-tier not-writable error
code."""
+ if not resp.is_error():
+ return (False, f"set should have failed but returned a result:
{resp.result!r}")
+ # Validate the structured error code rather than the message text so the
+ # test stays meaningful if the error wording is ever rephrased and so
+ # that any regression to the generic Codes::RECORD (2000) is caught.
+ if not resp.contains_nested_error(code=RECORD_READ_ONLY_CODE):
+ return (False, f"expected nested error code {RECORD_READ_ONLY_CODE}
(RECORD_READ_ONLY); got: {resp.error_as_str()}")
+ return (True, f"set was refused with code {RECORD_READ_ONLY_CODE}
(RECORD_READ_ONLY)")
+
+
+# Step 0: confirm the record is registered as RECA_READ_ONLY and starts at
+# its registered default value. This anchors the rest of the test against
+# the registration in RecordsConfig.cc -- if someone reclassifies the record
+# the test fails noisily here instead of silently exercising a permissive
+# write path.
+tr = Test.AddTestRun("Confirm record is RECA_READ_ONLY and at default")
+tr.Processes.Default.StartBefore(ts)
+tr.AddJsonRPCClientRequest(ts, lookup_request())
+tr.Processes.Default.Streams.stdout =
Testers.CustomJSONRPCResponse(assert_record_at_default)
+
+# Step 1: attempt to write the record via the management RPC. The handler
+# must reject the request with the "Record is not writable" error.
+tr = Test.AddTestRun("Attempt to set the RECA_READ_ONLY record (must be
refused)")
+tr.AddJsonRPCClientRequest(
+ ts, Request.admin_config_set_records([{
+ "record_name": READ_ONLY_RECORD,
+ "record_value": ATTEMPTED_VALUE,
+ }]))
+tr.Processes.Default.Streams.stdout =
Testers.CustomJSONRPCResponse(assert_set_was_rejected)
+
+# Step 2: re-look-up the record. Even if step 1's response had been
+# misleading, the record must still hold its default value -- this is the
+# assertion that catches the underlying bug at the storage level.
+tr = Test.AddTestRun("Confirm record was not modified")
+tr.AddJsonRPCClientRequest(ts, lookup_request())
+tr.Processes.Default.Streams.stdout =
Testers.CustomJSONRPCResponse(assert_record_at_default)