Repository navigation
[dbus-bridge] Decide Redfish access from the client certificate common name - #34
Conversation
The user manager looked the certificate's common name up in /etc/passwd and /etc/shadow and answered from the account it found, so the redfish container had to ship a bmcweb account with a password for Redfish to work at all. Decide access from the certificate instead. bmcweb authenticates the client by mTLS, and the bridge answers priv-admin for a common name that is trusted and no privileges for one that is not, so the container needs no local account. Read the trusted names from CONFIG_DB REDFISH|certs client_crt_cname, a comma separated list of exact names or wildcards of the form *.example.com, matching the handling of the SONiC REST API server. The list is read once at startup and held, as that server does, so a change takes effect when this service restarts. An unset list accepts any name, which keeps a device usable before the list has been pushed to it. Distinguish an unset field from a failed read: hget cannot, and that difference decides whether clients get in. A failed read refuses and is retried on the next request. Answer an untrusted name with a role carrying no privileges rather than a D-Bus error, because bmcweb maps an unexpected error to 500 and would report an untrusted client as a server fault. An empty role is answered with 403. Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
An unset or empty client_crt_cname accepted any common name from a certificate the staged CA issued, so a unit provisioned with a CA but no trusted list granted every holder of such a certificate full access. Refuse instead, as the SONiC REST API server does: its trusted list, split from an empty value, matches no certificate. Being issued by the staged CA is not enough on its own. Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
A client without a certificate from the staged CA does complete the TLS handshake, since bmcweb requests a client certificate without requiring one. It gets no session and is answered 401 before GetUserInfo is asked. Say so, and state that an empty trusted list matches nothing. Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
sonic-dbus-bridge now refuses every identity when REDFISH|certs client_crt_cname is not set. The tests authenticate as bmcweb, so seed that name before the bridge starts, as a provisioned unit would have it. Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
…n name rebuild-source: sonic-net/pull/34 @ nexthop-ai/sonic-redfish 2301e88 [case: upstream:open]
…n name rebuild-source: sonic-net/pull/34 @ nexthop-ai/sonic-redfish 2301e88 [case: upstream:open]
…n name rebuild-source: sonic-net/pull/34 @ nexthop-ai/sonic-redfish 2301e88 [case: upstream:open]
…ert-common-name Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
…n name rebuild-source: sonic-net/pull/34 @ nexthop-ai/sonic-redfish 582a53c [case: upstream:open]
…n name rebuild-source: sonic-net/pull/34 @ nexthop-ai/sonic-redfish 582a53c [case: upstream:open]
yxieca
left a comment
There was a problem hiding this comment.
Reviewed at 582a53c6. One blocking item; the authorization decision itself I checked carefully and it holds up.
Starting with what's right, since it's the part that mattered most to verify. Every path out of isCommonNameTrusted returns false — null adapter, failed load, empty list, no match — and getUserInfo emits a privilege only under trusted ? std::string("priv-admin") : std::string(""). The wildcard matching is also correct: suffix = entry.substr(1) keeps the leading dot, the commonName.size() > suffix.size() guard rejects example.com against *.example.com, and the suffix compare rejects evil-example.com (ends in -example.com) and example.com.attacker.net. Entry parsing trims each token and skips empties. I went looking for a fail-open and didn't find one.
Blocking — a Redis error reply is misclassified as "field not set", and then cached forever.
The function states its own reason for existing:
// Read directly rather than through hget(), which cannot distinguish a
// field that is not set from a read that failed. That difference decides
// whether client certificates are accepted, so it must not be lost.But after the null check, the only classification left is:
std::optional<std::string> result = std::string();
if (reply->type == REDIS_REPLY_STRING)
{
result = std::string(reply->str, reply->len);
}A REDIS_REPLY_ERROR — -LOADING while Redis is still coming up, -MISCONF, WRONGTYPE — is non-NULL and is not REDIS_REPLY_STRING, so it falls through as an empty string: a successful read of an unset field. That's precisely the distinction the comment says must not be lost, and it contradicts the contract added to redis_adapter.hpp in this same PR ("no value: CONFIG_DB could not be read").
What turns it from a classification bug into an availability defect is the caching. loadTrustedCnames() latches any non-nullopt result:
cachedCnames_ = *read;
haveCachedCnames_ = true;and the retry in isCommonNameTrusted is guarded by if (!haveCachedCnames_ && !loadTrustedCnames()). Once the flag is set it never reloads. So a transient Redis error during startup — the -LOADING window is the realistic one, since the bridge and Redis come up together — permanently refuses every client certificate until someone restarts sonic-dbus-bridge. The PR's stated behaviour, that a failed read "is retried on the next request until it succeeds", doesn't hold for this case.
It fails closed, so this isn't an escalation path. But the operator symptom is bad: all Redfish access denied, a log line saying no trusted common names are configured, and a CONFIG_DB that looks correctly populated when you go and check it.
Treating REDIS_REPLY_ERROR (and any type that isn't REDIS_REPLY_STRING or REDIS_REPLY_NIL) as std::nullopt, and logging reply->str, is a few lines and restores the tri-state the header promises.
Non-blocking:
-
There's no revocation path. Once
haveCachedCnames_is set, removing a common name from CONFIG_DB has no effect until the service restarts. The comment is honest about it and says it mirrors the REST API server, which is a defensible choice — but for an authorization boundary, "revoke access by knowing to restart a particular container" should live in operator-facing documentation rather than only in a source comment. A CONFIG_DB subscription or a bounded TTL would make it operational. -
*.comis accepted as a wildcard. The only rejection isentry.size() < 3, so a single-label wildcard trusts every common name in that TLD. Requiring at least two labels after the*would bound a plausible operator typo. -
An untrusted client still gets
UserGroups: {"redfish"}andUserEnabled: true. The whole denial rests on bmcweb mapping an emptyUserPrivilegeto no access. That's probably true today, but gating the group list and enabled flag ontrustedas well would make the refusal independent of one downstream consumer's behaviour. -
initUserObjects()hardcodes thebmcwebobject topriv-adminandenabled = trueregardless of whether any common name is trusted, and it runs beforeloadTrustedCnames()in the constructor. Harmless if every consumer goes throughGetUserInfo, but anything readingUserPrivilegeoff/xyz/openbmc_project/user/bmcwebdirectly seespriv-adminunconditionally. Worth either deriving it from the trusted-list state or noting that the object is display-only. -
A stale comment now sits directly above the changed constructor —
// (scans /etc/passwd on construction)inbridge_app.cpp, while this PR removes thegetpwnam_rlookup and the<pwd.h>/<shadow.h>includes. -
Refusals log at
LOG_ERRORper request, so any client holding a CA-issued certificate can generate unbounded error volume, and a malformed config entry is re-warned on every request rather than once at load. Validating entries inloadTrustedCnames()and demoting the per-request refusal would keep the signal useful.
Two questions I couldn't settle from the diff:
getRedfishClientCnames()is the only accessor that reaches intoconfigDbContext_directly;hgetall()andhget()both take a caller-suppliedredisContext*. IfconfigDbContext_is non-NULL but in a sticky error state after a failedredisCommand, does anything reconnect it? If not, the retry path can't recover even once the blocking issue above is fixed.isUserExistandisUserEnabledare removed from the header whileUsersis still constructed with aUserMgr&back-reference. The PR says it builds clean, which likely settles it, butusers.cppisn't in the diff.
One cross-PR note: sonic-mgmt #28424 is the test suite for this feature, and its test_wrong_ca_rejected has an except (requests.exceptions.SSLError, ssl.SSLError) branch that asserts nothing. Any TLS-layer failure satisfies it — so that test would still pass if this code stopped rejecting untrusted certificates entirely. The implementation and its test currently have gaps on the same boundary, which is worth closing from both ends.
AI-assisted review, reviewed by Ying Xie (@yxieca).
…n name rebuild-source: sonic-net/pull/34 @ nexthop-ai/sonic-redfish 582a53c [case: upstream:open]
The trusted common name list was read once and held until the service restarted. A Redis error reply was taken as an unset field and held too, so a read during Redis startup refused every client until a restart, and removing a name never took effect. - Read client_crt_cname from CONFIG_DB on every GetUserInfo call, so a change takes effect on the next request. A failed read refuses. - Treat any reply other than a string or nil as a failed read, and reconnect after an I/O error instead of reusing a broken context. - Answer an untrusted common name with no groups and not enabled, as well as no role, so the refusal does not rest on the role alone. The keys stay present, since bmcweb answers 500 to a reply without them. - Log a refused common name at info: any client with a certificate from the CA can trigger it on every request. - Note that the Redfish identity object is display only, and drop a stale comment about scanning /etc/passwd. Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
Thanks @yxieca, I have addressed your reviews comments: Blocking: Error replies like -LOADING are now treated as a failed read instead of an unset field, and we log the error string. I am not caching it anymore. 1: I dropped the cache entirely. The list is now read from CONFIG_DB on every GetUserInfo call. bmcweb already calls that per request. On your questions: you were right that nothing reconnected the context before, and that's fixed now. |
yxieca
left a comment
There was a problem hiding this comment.
Re-reviewed at 1348c3b. The blocking item is fixed: getRedfishClientCnames() now classifies REDIS_REPLY_STRING as a value, REDIS_REPLY_NIL as an unset field, and everything else — including REDIS_REPLY_ERROR — as std::nullopt, restoring the tri-state the header documents. Dropping the cache resolves it more thoroughly than correcting the classification alone would have, since revocation now takes effect immediately rather than at restart, and the reconnect on a dropped context closes the sticky-error question from the last round. Fail-closed re-verified on all four paths, and an untrusted common name now gets empty groups and UserEnabled false rather than relying on one downstream mapping.
AI-assisted review, reviewed and approved by Ying Xie (@yxieca).
…ess (#28424) ### Description of PR Summary: Adds sonic-mgmt coverage for serving provisioned certificates from the redfish container and for deciding Redfish access from the client certificate common name, as described in the Redfish certificate HLD (sonic-net/SONiC#2461). The image side is the certificate staging in the redfish container (sonic-net/sonic-buildimage#28276) and the common-name check in sonic-dbus-bridge (sonic-net/sonic-redfish#34). These tests need an image carrying both, on top of host networking for the redfish container (sonic-net/sonic-buildimage#29747). ### Type of change - [ ] Bug fix - [ ] Testbed and Framework(new/improvement) - [x] New Test case - [x] Skipped for non-supported platforms - [ ] Test case improvement ### Back port request - [ ] 202311 - [ ] 202405 - [ ] 202411 - [ ] 202505 - [ ] 202511 - [ ] 202512 - [ ] 202605 ### Tested branch - [x] master - [ ] 202311 - [ ] 202405 - [ ] 202411 - [ ] 202505 - [ ] 202511 - [ ] 202512 - [ ] 202605 - [ ] N/A ### Test result - master + sonic-net/sonic-buildimage#28276 + sonic-net/sonic-redfish#34 (aspeed): `test_redfish_certificates.py` 37 passed, 0 failed, 0 errors ### Approach #### What is the motivation for this PR? The certificate flow has many states that only show up on a real device: first provisioning, rotation, container restart and recreate, partial or corrupt input, fail closed after provisioning, and the common-name allow list. `test_redfish_cert_auth.py` copied certificates straight into the container and checked a single mTLS request. #### How did you do it? - `tests/redfish/conftest.py`: the `bmc_tls_certs` fixture provisions certificates the way a provisioning agent does. It writes the CA, server certificate and key to the paths in CONFIG_DB `REDFISH|certs`, configures the trusted common names, and waits for STATE_DB `REDFISH_CERT_STATUS|global` to report them applied. On teardown it returns the device to the state it arrived in. A `redfish_port` fixture follows the port bmcweb listens on rather than assuming 443. - `tests/redfish/redfish_utils.py`: the shared constants and helpers (certificate generation from one CA, delivery, database access, supervisor control) move here from `conftest.py`. - `tests/redfish/test_redfish_certificates.py` replaces `test_redfish_cert_auth.py`. Its 14 classes cover: - provisioning and status reporting; - mTLS, and the common-name rules: exact names, wildcards, lists, and an unset list refusing every client; - restart behaviour, fail closed after provisioning, recovery, and container recreate; - negative input and a first-install key/certificate mismatch; - watcher resilience and observability; - bootstrap, configured delivery paths, configured port, and CA rotation. #### How did you verify/test it? Ran the full module on a physical Aspeed BMC on the bmc-shared-mgmt topology, with an image built from master plus sonic-net/sonic-buildimage#28276 and sonic-net/sonic-redfish#34: 37 passed. #### Any platform specific information? SONiC BMC only (physical Aspeed BMC with the redfish feature). #### Supported testbed topology if it's a new test case? bmc (bmc-shared-mgmt, bmc-dual-mgmt) ### Documentation --------- Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
…il closed once provisioned (#3406) #### Why I did it Manual cherry-pick of sonic-net/sonic-buildimage#28276 to 202608. The automatic cherry-pick failed with a conflict. ##### Work item tracking - Microsoft ADO **(number only)**: #### How I did it `git cherry-pick -x 89ac685`. The only conflict was `rules/docker-sonic-redfish.dep`, which the original change modified to add `dockers/dockerfile-macros.j2` to the image cache dependencies. That file does not exist on 202608 (the redfish docker is not cache tracked here), so the change to it is dropped. `Dockerfile.j2`, `stage-credentials.sh`, `supervisord.conf` and `redfish.service.j2` merged cleanly and are identical to master after #28276. 202608 already carries #29747 (host networking) and pins `src/sonic-redfish` at the sonic-net/sonic-redfish#34 merge, which this change depends on. See sonic-net/sonic-buildimage#28276 for the full description. #### How to verify it Same as sonic-net/sonic-buildimage#28276. #### Which release branch to backport (provide reason below if selected) - [x] 202608 #### Description for the changelog [redfish] Stage provisioned certificates for bmcweb and fail closed once provisioned Signed-off-by: Shreyansh Jain <shreyansh@nexthop.ai>
… and common-name access (#1493) ### Description of PR Summary: Adds sonic-mgmt coverage for serving provisioned certificates from the redfish container and for deciding Redfish access from the client certificate common name, as described in the Redfish certificate HLD (sonic-net/SONiC#2461). The image side is the certificate staging in the redfish container (sonic-net/sonic-buildimage#28276) and the common-name check in sonic-dbus-bridge (sonic-net/sonic-redfish#34). These tests need an image carrying both, on top of host networking for the redfish container (sonic-net/sonic-buildimage#29747). ### Type of change - [ ] Bug fix - [ ] Testbed and Framework(new/improvement) - [x] New Test case - [x] Skipped for non-supported platforms - [ ] Test case improvement ### Back port request - [ ] 202311 - [ ] 202405 - [ ] 202411 - [ ] 202505 - [ ] 202511 - [ ] 202512 - [ ] 202605 ### Tested branch - [x] master - [ ] 202311 - [ ] 202405 - [ ] 202411 - [ ] 202505 - [ ] 202511 - [ ] 202512 - [ ] 202605 - [ ] N/A ### Test result - master + sonic-net/sonic-buildimage#28276 + sonic-net/sonic-redfish#34 (aspeed): `test_redfish_certificates.py` 37 passed, 0 failed, 0 errors ### Approach #### What is the motivation for this PR? The certificate flow has many states that only show up on a real device: first provisioning, rotation, container restart and recreate, partial or corrupt input, fail closed after provisioning, and the common-name allow list. `test_redfish_cert_auth.py` copied certificates straight into the container and checked a single mTLS request. #### How did you do it? - `tests/redfish/conftest.py`: the `bmc_tls_certs` fixture provisions certificates the way a provisioning agent does. It writes the CA, server certificate and key to the paths in CONFIG_DB `REDFISH|certs`, configures the trusted common names, and waits for STATE_DB `REDFISH_CERT_STATUS|global` to report them applied. On teardown it returns the device to the state it arrived in. A `redfish_port` fixture follows the port bmcweb listens on rather than assuming 443. - `tests/redfish/redfish_utils.py`: the shared constants and helpers (certificate generation from one CA, delivery, database access, supervisor control) move here from `conftest.py`. - `tests/redfish/test_redfish_certificates.py` replaces `test_redfish_cert_auth.py`. Its 14 classes cover: - provisioning and status reporting; - mTLS, and the common-name rules: exact names, wildcards, lists, and an unset list refusing every client; - restart behaviour, fail closed after provisioning, recovery, and container recreate; - negative input and a first-install key/certificate mismatch; - watcher resilience and observability; - bootstrap, configured delivery paths, configured port, and CA rotation. #### How did you verify/test it? Ran the full module on a physical Aspeed BMC on the bmc-shared-mgmt topology, with an image built from master plus sonic-net/sonic-buildimage#28276 and sonic-net/sonic-redfish#34: 37 passed. #### Any platform specific information? SONiC BMC only (physical Aspeed BMC with the redfish feature). #### Supported testbed topology if it's a new test case? bmc (bmc-shared-mgmt, bmc-dual-mgmt) Signed-off-by: Sonic Build Admin <sonicbld@microsoft.com> ### Documentation
What this does
Decides Redfish access from the client certificate rather than from a local Unix account, and validates the certificate's common name against a trusted list in CONFIG_DB.
UserMgrpreviously looked the common name up in/etc/passwdand/etc/shadow, which meant the redfish container had to ship abmcwebaccount with a password. It now holds no accounts: bmcweb authenticates the client by mTLS, and the bridge answerspriv-adminfor a trusted common name, and no privileges, no groups and not enabled for an untrusted one.Common name matching
RedisAdapter::getRedfishClientCnames()readsREDFISH|certsfieldclient_crt_cname, a comma separated list. Entries are exact names, or wildcards of the form*.example.commatching any name ending in.example.combut notexample.comitself. Matching is case sensitive, and entries beginning with*that are not usable wildcards are skipped with a warning.This mirrors
CommonNameMatchin the SONiC REST API server. The two were compared over exact matches, subdomain and multi level subdomain matches, the domain itself, a suffix that merely ends with the domain, malformed wildcard entries and a case mismatch, and they agree on every case.An unset or empty list refuses every common name, as the REST API server does: its trusted list, split from an empty value, matches no certificate. Being issued by the staged CA is not enough on its own. The list is read from CONFIG_DB on every
GetUserInfocall, which bmcweb already makes per request, so adding or removing a name takes effect on the next request without restarting the service.Failure behaviour
The read returns a tri-state rather than a string, because
hget()cannot distinguish a field that is not set from a read that failed. A string reply is the configured list and a nil reply is an unset field; any other reply, such as-LOADINGwhile Redis starts, is a failed read and is logged. Every failure refuses the request. After an I/O error the CONFIG_DB context is freed and the next read reconnects, as it does when CONFIG_DB was not reachable at startup.An untrusted common name is answered with a role carrying no privileges, empty groups and
UserEnabledfalse, not a D-Bus error: bmcweb maps an unexpected error to 500, which would report an untrusted client as a server fault. An empty role fails the privilege check and is answered with 403. The keys stay present, because bmcweb answers 500 to a local user reply withoutUserGroups. A refused common name is logged at info, since any client holding a certificate from the CA can trigger it on every request.The
bmcwebuser object the bridge publishes is for the AccountService listing only; its privilege and enabled flag grant nothing.Redfish defines ServiceRoot as requiring no privileges, so
/redfish/v1still answers 200 for a refused common name. The difference shows on privileged paths such as/redfish/v1/Managers.Verification
Builds clean. Behaviour was exercised on a BMC before the per request read: a trusted common name reaches privileged paths, an untrusted one is refused with 403 while the service root still answers. The per request read, error reply handling, reconnect and the refusal for an unset or empty list have been built and not yet exercised on a BMC.
No unit test exercises the matcher; the repository's unit tests cover field mapping and the inventory model only.
Related
The CA these certificates are validated against is staged by sonic-net/sonic-buildimage#28276