Accept NSS aliases for canonicalized user records#42452
Conversation
Claude review of PR #42452 (650bb87)Must fix
Suggestions
Nits
Update for 9201f8a: this revision (now two commits: implementation Update for 650bb87: this revision (implementation |
| if (r < 0) | ||
| return r; | ||
|
|
||
| if (!streq(name, result->pw_name)) { |
There was a problem hiding this comment.
Claude: suggestion: Both this guard (streq(name, result->pw_name)) and the later matching in user_record_matches_user_name() (streq/strv_contains) are case-sensitive. NSS/AD/SSSD names are commonly case-insensitive, so the originally requested name frequently differs from the canonical pw_name only in case (e.g. requested [email protected] vs returned testuser). Storing the verbatim requested string does help that exact spelling, but any subsequent lookup that differs only in case from the recorded alias will still be rejected as ConflictingRecordFound. If case-insensitive providers are the motivating use case, consider whether the storage/matching should account for case rather than relying on byte-exact comparison.
There was a problem hiding this comment.
Case-insensitive matching is a deeper fix, which I think is out the scope of this PR.
This change is intentionally narrow: it preserves the exact lookup name that NSS already resolved, so canonicalized NSS results continue to match the original request.
6b840fd to
8bf7bd4
Compare
| 'test-format-util.c', | ||
| 'test-fs-util.c', | ||
| 'test-fstab-util.c', | ||
| 'test-group-record-nss.c', |
There was a problem hiding this comment.
Claude: nit: test-group-record-nss.c is inserted between test-fstab-util.c and test-glob-util.c, breaking the otherwise-alphabetical ordering of this list (glob < gpt < group < gunicode). It should be placed after test-gpt.c and before test-gunicode.c. The sibling test-user-record-nss.c was added in the correct position.
8bf7bd4 to
d61dda5
Compare
| incomplete = true; | ||
|
|
||
| r = nss_passwd_to_user_record(result, sresult, ret); | ||
| r = nss_passwd_to_user_record(result, sresult, name, ret); |
There was a problem hiding this comment.
Claude: suggestion: The NSS by-name path records the originally-requested lookup name as an alias on the returned record (and the same at the group path). Downstream, the userdb worker validates the record with user_record_matches_user_name(hr, p.name) and otherwise rejects it with ConflictingRecordFound. Because the requested name is added as an alias during the same lookup (whenever it passes valid_user_group_name(VALID_USER_RELAX) and differs from the canonical name), that self-check now always passes for any NSS-backed by-name lookup, relaxing the defense-in-depth check that previously caught NSS modules returning a record whose pw_name does not correspond to the requested name.
This is not a privilege escalation in itself — the uid/gid come solely from the NSS record, and a hostile NSS module could already return the requested name as pw_name directly. Flagging for awareness: if you want the conflict check to retain meaning for genuinely mismatched NSS results, consider scoping alias recording more narrowly than "any syntactically-valid requested name" (e.g. only the canonicalization case the PR targets).
There was a problem hiding this comment.
I agree with this concern, but I am not yet sure what the right constraint should be here. Would checking that the requested name has the form <pw_name>@... be an acceptable way to scope this more tightly?
There was a problem hiding this comment.
so the user_record_matches_user_name() check in userwork.c exists mostly to ensure that if people do a lookup where they specify both UID and name, that we fail if the record we found doesn't match both. because that would be weird.
now, if both UID and name are specified we currently do the lookup via UID, and then check the name afterwards. I guess this should be switched so that we do the lookup by name instead, so that for records like yours we definitely have the right alias in the record, and the test will then succeed.
| ASSERT_NULL(sd_json_variant_by_key(u->json, "aliases")); | ||
| } | ||
|
|
||
| DEFINE_TEST_MAIN(LOG_INFO); |
There was a problem hiding this comment.
Claude: suggestion: Two coverage gaps in the new user-side test (the production logic is correct; these are just missing cases):
- The user-side
alias@realmmatching branch is untested.user_record_matches_user_name()gained the same realm-stripping logic as the group side, and the group test exercises it vianss_group_alias_realm, but there is no user equivalent, so a regression in the user realm branch would go undetected. Mirrornss_group_alias_realm: build a record with an alias, setu->realm, then assertuser_record_matches_user_name(u, "<alias>@<realm>"). - The
nss_add_valid_alias()no-op branches are untested:alias_nameequal to the canonical name (must not add a redundant alias nor emit analiasesJSON key) and the NULL/emptyalias_namepath used by the by-uid/by-gid and iterator callers.
| r = sd_json_buildo( | ||
| &hr->json, | ||
| SD_JSON_BUILD_PAIR_STRING("userName", hr->user_name), | ||
| SD_JSON_BUILD_PAIR_CONDITION(!strv_isempty(hr->aliases), "aliases", SD_JSON_BUILD_STRV(hr->aliases)), |
There was a problem hiding this comment.
JSON_BUILD_PAIR_STRV_NON_EMPTY()
| incomplete = true; | ||
|
|
||
| r = nss_passwd_to_user_record(result, sresult, ret); | ||
| r = nss_passwd_to_user_record(result, sresult, NULL, ret); |
There was a problem hiding this comment.
we generally want in new code that "opaque" params in func calls (i.e. those which give you no hint what you are passing there) are prefixed with a param comment. i.e. for the 3rd argument here write /* name= */ NULL instead of just NULL.
| r = sd_json_buildo( | ||
| &g->json, | ||
| SD_JSON_BUILD_PAIR_STRING("groupName", g->group_name), | ||
| SD_JSON_BUILD_PAIR_CONDITION(!strv_isempty(g->aliases), "aliases", SD_JSON_BUILD_STRV(g->aliases)), |
| incomplete = true; | ||
|
|
||
| r = nss_group_to_group_record(result, sresult, ret); | ||
| r = nss_group_to_group_record(result, sresult, NULL, ret); |
| } | ||
|
|
||
| r = nss_passwd_to_user_record(pw, r >= 0 ? &spwd : NULL, ret); | ||
| r = nss_passwd_to_user_record(pw, r >= 0 ? &spwd : NULL, NULL, ret); |
| } | ||
|
|
||
| r = nss_group_to_group_record(gr, r >= 0 ? &sgrp : NULL, ret); | ||
| r = nss_group_to_group_record(gr, r >= 0 ? &sgrp : NULL, NULL, ret); |
d61dda5 to
d64481a
Compare
d64481a to
937a144
Compare
|
|
||
| static const sd_json_dispatch_field group_dispatch_table[] = { | ||
| { "groupName", SD_JSON_VARIANT_STRING, json_dispatch_user_group_name, offsetof(GroupRecord, group_name), SD_JSON_RELAX }, | ||
| { "aliases", SD_JSON_VARIANT_ARRAY, json_dispatch_user_group_list, offsetof(GroupRecord, aliases), SD_JSON_RELAX }, |
There was a problem hiding this comment.
Claude: suggestion: The new aliases field is added only to the top-level group_dispatch_table, but not to the group per_machine_dispatch_table. On the user side aliases is dispatched in both the main user_dispatch_table and the perMachine table, so a GroupRecord that carries aliases inside a perMachine block round-trips asymmetrically: the user loader honors per-machine aliases while the group loader silently drops them. The NSS path this PR targets is unaffected (it writes the top-level aliases key), but for group-vs-user symmetry of the struct/JSON mapping consider also adding the aliases entry to the group perMachine table.
| ASSERT_TRUE(group_record_matches_group_name(g, "[email protected]")); | ||
| } | ||
|
|
||
| DEFINE_TEST_MAIN(LOG_INFO); |
There was a problem hiding this comment.
Claude: suggestion: The new tests cover group_record_matches_group_name()/user_record_matches_user_name() and the JSON round-trip, but neither test file exercises the other new branch added by this PR: the alias-aware fuzzy match in group_record_match() and user_record_match(), which now additionally calls user_name_fuzzy_match() over ->aliases. A record carrying only an alias (no matching name or realm) looked up via a UserDBMatch with fuzzy_names is currently untested, so a regression in that branch would go undetected. This is distinct from the already-noted user-side alias@realm and nss_add_valid_alias() no-op coverage gaps.
| bool incomplete; | ||
|
|
||
| char *group_name; | ||
| char **aliases; |
There was a problem hiding this comment.
Claude: nit: For symmetry with UserRecord — where aliases is placed after user_name, realm, and user_name_and_realm_auto — consider moving this aliases field to after group_name_and_realm_auto instead of between group_name and realm (and reorder the matching strv_free() in group_record_free() accordingly). Purely cosmetic, no behavioral impact.
b7791ef to
45eb03e
Compare
|
|
||
| #include <pwd.h> | ||
| #include <string.h> | ||
| #include "sd-json.h" |
There was a problem hiding this comment.
Claude: nit: Claude: Per systemd's include conventions, sd-* public-library headers form their own group separated from the preceding system headers by a blank line (compare test-user-record.c). Here #include "sd-json.h" is glued directly onto the <pwd.h>/<string.h> block (note the file already leaves a blank line after it, before the internal headers). Add a blank line before it so the grouping reads system headers / sd-* headers / internal headers.
|
|
||
| #include <grp.h> | ||
| #include <string.h> | ||
| #include "sd-json.h" |
There was a problem hiding this comment.
Claude: nit: Claude: Per systemd's include conventions, sd-* public-library headers form their own group separated from the preceding system headers by a blank line (compare test-user-record.c). Here #include "sd-json.h" is glued directly onto the <grp.h>/<string.h> block (note the file already leaves a blank line after it, before the internal headers). Add a blank line before it so the grouping reads system headers / sd-* headers / internal headers.
45eb03e to
9201f8a
Compare
| const char *realm = strrchr(group_name, '@'); | ||
| if (realm && streq_ptr(realm+1, g->realm)) | ||
| STRV_FOREACH(a, g->aliases) | ||
| if (startswith(group_name, *a) == realm) | ||
| return true; |
There was a problem hiding this comment.
Claude: suggestion: The new alias@realm matching block here is byte-for-byte identical to the one in user_record_matches_user_name() in src/shared/user-record.c (only g->/group_name vs u->/user_name differ): same strrchr(name, '@'), realm-suffix compare, and STRV_FOREACH with startswith(name, *a) == realm. Consider factoring it into a shared helper taking (name, aliases, realm) so the user and group matchers can't drift apart as one side is later tweaked.
|
|
||
| ASSERT_TRUE(group_record_match(g, &match)); | ||
| userdb_match_done(&match); | ||
| } |
There was a problem hiding this comment.
Claude: nit: The user test exercises both no-op branches of nss_add_valid_alias() — nss_user_alias_same_as_canonical_noop (alias equals canonical name) and nss_user_null_alias_noop (NULL alias) — but the group test has no equivalents. Since the PR's goal is symmetric alias handling, consider adding nss_group_alias_same_as_canonical_noop and nss_group_null_alias_noop so the skip-if-empty / skip-if-equal policy is covered on the group side too.
NSS lookups may resolve an alias-like name to a canonical user or group name. For example, SSSD can resolve a userPrincipalName such as "[email protected]" while returning a passwd record whose pw_name is the canonical login name "testuser". The NSS userdb worker currently converts such records using only the canonical name. As a result, the originally requested lookup name is lost, and lookups through the varlink/multiplexer path may fail with ConflictingRecordFound even though direct NSS lookup succeeds. Preserve the originally requested lookup name as an alias when it differs from the canonical passwd/group name and passes normal alias validation. Add the alias both to the parsed record fields and to the JSON representation so serialized records remain self-consistent. Extend group record loading and matching to understand aliases too. When both a name and UID/GID are specified in a userdb lookup, prefer the by-name path so alias-aware records can satisfy the subsequent consistency checks. This allows canonicalized NSS results to match the original lookup name correctly and fixes session setup for users logging in with NSS-resolvable aliases such as UPNs.
Add separate NSS conversion tests for user and group records covering canonicalized lookup aliases, invalid alias rejection, user-side alias no-op cases, group alias@realm matching, and alias-aware fuzzy matching.
9201f8a to
650bb87
Compare
This PR fixes userdb lookups for NSS users that are resolved through an alias but returned with a canonical user name.
Some NSS providers, such as SSSD, can successfully resolve a user by an alias-like name, for example a Kerberos/AD UPN (for example [email protected]) while returning a passwd record with the canonical login name.
The original lookup name was not preserved. Later, the userdb worker checked whether the returned record matched the requested name with user_record_matches_user_name(). Since the requested name was [email protected], but the record only contained testuser, the lookup was rejected as:
io.systemd.UserDatabase.ConflictingRecordFoundThis also caused pam_systemd to fail opening sessions for such users with:
pam_systemd(...:session): Failed to get user recordIn my case, this broke graphical logins for Samba users logging in with a UPN on systems where SSSD canonicalizes the NSS result.
My solution preserves the requested name as a UserRecord alias when an NSS lookup by name succeeds but the returned pw_name differs from the requested name.
This allows user_record_matches_user_name() to accept the canonicalized NSS result instead of treating it as a conflicting record.
The patch does not invent new aliases. It only records the name that NSS itself already accepted and resolved successfully.
I'm not an expert on working with systemd, so I'm asking for a review of my PR.