Skip to content

Allow creating a local user without a password - #1090

Merged
cigamit merged 3 commits into
ctrliq:mainfrom
quanah:issue1089
Oct 13, 2026
Merged

cigamit merged 3 commits into
ctrliq:mainfrom
quanah:issue1089

Conversation

@quanah

@quanah quanah commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor
SUMMARY

Assisted-by: Claude Opus 5.5. Drafted with AI assistance; the commits carry the same trailer.

Lets POST /api/v2/users/ create a local user without a password, giving it an unusable one, for accounts that only ever authenticate with an OAuth2 token.

UserSerializer._update_password already calls set_unusable_password() when no password is given, so the only change is that validate_password returns early for a new user with no password, before Django's validate_password() and instead of raising Password required for new User. Passwords that are supplied keep every existing check. Updating an existing user is unchanged.

A second commit stops RADIUS from claiming these accounts. user_is_in_enterprise_category() treated any user without a usable password as a RADIUS user, a fallback for users created before UserEnterpriseAuth existed, so a RADIUS principal with the same username could have logged in as a password-less local account. RADIUS now needs a provider='radius' UserEnterpriseAuth row, as TACACS+ already does. Every RADIUS login since that model was added (2017, before AWX 1.0.0) created one, so existing RADIUS users are unaffected. This is the same change the RADIUS removal in #1068 makes to that function.

Other readers of the password state were checked and don't adopt these accounts: LDAP requires ldap_dn, TACACS+ requires its row, SAML and the other social backends match on their own association (or an email match, independent of the password), and get_external_account() reports them as local.

Same change as ansible/awx#16688 (for ansible/awx#16687), adjusted for Ascender's extra Django-validator call.

related #1089

ISSUE TYPE
  • New or Enhanced Feature
COMPONENT NAME
  • API
ASCENDER VERSION
awx: 25.6.2-241-gc5da6e18
ADDITIONAL INFORMATION

Getting a token for a password-less account. The API can't issue one: token creation always uses the requesting user. As an admin, POST /api/v2/users/<id>/personal_tokens/ returns 201, but the token belongs to the admin, not <id>. Create the token on the controller instead:

awx-manage create_oauth2_token --user svc-ci

It prints a personal access token (no application, write scope, expiry from OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS']) that authenticates as svc-ci. ascender-manage is the same command. These accounts count as local, so turning on DISABLE_LOCAL_AUTH revokes their tokens as it does for other local users. Letting an admin issue a token for another user through the API is a separate change, not part of this PR.

Tests. test_user_create_without_password (password omitted and ""), test_user_create_without_password_ignores_complexity_settings and test_user_created_without_password_is_not_a_radius_user in test_user.py (the last one end to end through RADIUSBackend with a stubbed RADIUS reply); test_radius_does_not_adopt_local_user_without_password and test_radius_user_with_enterprise_auth_logs_in in test_get_or_set_enterprise_user.py.

# new and changed tests (test_user.py, test_get_or_set_enterprise_user.py)
with both commits:   10 passed
first commit only:   2 failed, 8 passed
FAILED ...test_user.py::test_user_created_without_password_is_not_a_radius_user
       AssertionError: assert <User: affable> is None
FAILED ...test_get_or_set_enterprise_user.py::test_radius_does_not_adopt_local_user_without_password
       AssertionError: assert <User: svc> is None

# whole CI test set (ascender/main/tests/unit ascender/main/tests/functional ascender/conf/tests ascender/sso/tests)
4614 passed, 6 skipped, 5 failed; the same 5 fail without the RADIUS commit:
  test_settings.py::test_logging_aggregator_connection_test_valid
  test_schedule.py::test_timezone_property (3 cases)
  test_external_logging_durability.py::test_a_spool_directory_that_cannot_be_entered_falls_back
ascender-manage check_migrations / check_settings: clean
ruff 0.16.6 (tox.ini pin) format --check + check: clean

@quanah
quanah force-pushed the issue1089 branch 2 times, most recently from df86704 to 602ccdc Compare October 8, 2026 18:43
@cigamit cigamit self-assigned this Oct 10, 2026
@cigamit cigamit added the enhancement New feature or request label Oct 10, 2026
@cigamit
cigamit requested a balanced review from Copilot October 10, 2026 00:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused implementation matches the requested behavior and includes appropriate regression coverage.

0 open findings

What changed in this PR

Enables token-only local accounts by allowing user creation without a password and assigning an unusable password.

Changes:

  • Bypasses password validation when a new user omits or blanks the password.
  • Adds functional coverage for omitted, blank, and complexity-policy scenarios.
File Description
ascender/​api/​serializers/​user.py Allows passwordless creation while preserving validation for supplied passwords.
ascender/​main/​tests/​functional/​api/​test_user.py Verifies passwordless users receive unusable passwords.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@cigamit

cigamit commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this. The change does what it says: an API-created user with no password, or "", gets an unusable password, password login is refused, and supplied passwords are still validated. While testing it I found two things that need sorting out before we can merge.

1. A RADIUS login can take over password-less local users

user_is_in_enterprise_category() in ascender/main/models/__init__.py has an old fallback that treats any user without a usable password as a RADIUS user:

if category == 'radius':
    ret = ret or not user.has_usable_password()

Before this PR, the API couldn't create a local user in that state. Now it can, so when RADIUS is configured, _get_or_set_enterprise_user() hands the local account to any RADIUS principal with the same username. I confirmed this by calling RADIUSBackend.authenticate() with a stubbed RADIUS reply:

Local user Usable password Result of RADIUS login
svc (superuser, created without a password) No Logged in as svc
weak (created with a password) Yes Refused

So a RADIUS user named svc-ci becomes the token-only service account, superuser rights included. LDAP and TACACS+ are not affected. LDAP requires ldap_dn, and TACACS+ requires a UserEnterpriseAuth row.

Suggested fix: the comment there already says to remove the fallback "in a future release". Either drop it, or require a UserEnterpriseAuth row with provider='radius', and add a test that a password-less local user is not adopted by RADIUS. ansible/awx #16688 has the same exposure upstream.

(This might not be a big deal though, as we have another PR that drops RADIUS auth)

2. The API has no way to give these accounts a token

The PR targets accounts that only authenticate with an OAuth2 token, but nothing in the API can create a token for a user who can't log in:

  • Every token serializer sets user = request.user.
  • The application's user field is hidden from the API (-user).
  • As an admin, POST /api/v2/users/<svc-id>/personal_tokens/ returns 201, but the token belongs to the admin, not <svc-id>. Automation that only checks the status code will think it succeeded.

The only working route I found is awx-manage create_oauth2_token --user <username>, which needs shell access to the controller. At minimum, please document that in the PR and the issue so the declarative-tooling use case in #1089 is set up properly. If admins should be able to issue tokens for service accounts through the API, that's probably a follow-up change.

@quanah

quanah commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for testing this so thoroughly. Pushed 4a06149 (Stop treating every password-less user as a RADIUS user):

1. RADIUS takeover: fixed by dropping the fallback. With it gone, RADIUS needs a provider='radius' UserEnterpriseAuth row, as TACACS+ does. Dropping it and requiring the row come to the same thing, since the function's first line already checks for the row. The change is the same one #1068 makes to that function, with the same comment, so whichever merges second has nothing to resolve there.

  • Existing RADIUS users: every RADIUS login since UserEnterpriseAuth was added (2017, before AWX 1.0.0) created the row, and nothing deletes rows, so they keep working. Only a user from before that, never given a row, would now be refused (Enterprise user ... already defined).
  • The fallback was already reachable without this PR: awx-manage createsuperuser --noinput without a password creates a superuser with no usable password.
  • Tests: a password-less local user (superuser, created through the API) isn't adopted by RADIUSBackend.authenticate() with a stubbed reply, while a new RADIUS username still logs in. At the helper level, the same user isn't adopted and a user with a RADIUS row still is. Both new adoption tests fail on the current head.
  • I checked the other readers of the password state too. LDAP requires ldap_dn, TACACS+ requires its row, and get_external_account() treats these accounts as local, so they can hold tokens. DISABLE_LOCAL_AUTH revokes their tokens like any other local user's. SAML and the social backends never look at the password. They link an existing user through social_auth, or through associate_by_email when the emails match, but that applies to every local user, has nothing to do with this PR, and I haven't changed it.

2. Tokens: confirmed. Creating a personal token as admin for another user gives 201 with the token owned by the admin, and awx-manage create_oauth2_token --user <username> is the only route. That token authenticates as the service account. I've added this to the PR and issue descriptions. API-issued tokens for service accounts would be a separate issue.

Full TEST_DIRS run: 4614 passed, 5 failed, all 5 failing the same way without this commit (schedule timezones, the logging connection test, a spool-directory permission test run as root). ruff 0.16.6, check_migrations and check_settings are clean.

validate_password rejected a new user with no password, although
_update_password already stores an unusable password in that case. A
token-only service account then needs a password nobody should use.
Return early for a new user with no password; supplied passwords keep
every existing check, and updates are unchanged.

related ctrliq#1089

Assisted-by: Claude Opus 5.5
Signed-off-by: Quanah Gibson-Mount <quanah@dropbox.com>
user_is_in_enterprise_category() accepted any user without a usable
password as a RADIUS user, a fallback for accounts created before
UserEnterpriseAuth existed. With password-less local users now creatable
through the API, a RADIUS principal with the same username could log in
as such an account, superuser included.

Drop the fallback, so RADIUS needs a provider='radius' UserEnterpriseAuth
row, as TACACS+ already does. Every RADIUS user created since that model
was introduced (2017, before AWX 1.0.0) has one, so existing RADIUS users
keep logging in.

Tests: a password-less local user is not adopted by RADIUS, through the
helper and end to end through RADIUSBackend; a user with a RADIUS row
still is.

related ctrliq#1089

Assisted-by: Claude Opus 5.5
Signed-off-by: Quanah Gibson-Mount <quanah@dropbox.com>
@quanah

quanah commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

awx

On ansible/awx#16688: it doesn't need the RADIUS change. AWX itself no longer authenticates RADIUS users: ansible/awx#15548 removed it, user_is_in_enterprise_category() is gone, and AWX doesn't load django-ansible-base's authentication app. RADIUS moved to the AAP platform gateway, where django-ansible-base matches a login to an existing account by its authenticator link or by email, never by an unusable password, so this heuristic doesn't exist there either.

@cigamit cigamit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, tested everything again and its now all working as expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Development

Successfully merging this pull request may close these issues.

3 participants