Skip to content

Let an administrator create a personal access token for another user #1116

Open

Description

@quanah

Please confirm the following

  • I have checked the current issues for duplicates.
  • I understand that Ascender is open source software provided for free and that I might not receive a timely response.

Feature type

Enhancement to Existing Feature

Feature Summary

Assisted-by: Claude Opus 5.5. Drafted with AI assistance; every code path cited was read at main c5da6e18, and every result below was reproduced with functional tests against that commit.

Let a system administrator create a personal access token for another user through POST /api/v2/users/<id>/personal_tokens/, typically for a service account created without a password (#1089 / #1090), which can't log in to create its own.

Today the API has no way to do this. Every token serializer sets the owner to the requesting user (BaseOAuth2TokenSerializer.create), so POST /api/v2/users/<id>/personal_tokens/ ignores <id>: as an admin it returns 201 with a token that belongs to the admin, which reads like success. GET on the same URL lists the requester's tokens whatever <id> is. The only way to give another user a token is awx-manage create_oauth2_token --user <name> on the controller, which declarative tooling that drives the API can't reach.

Select the relevant components

  • UI
  • API
  • Docs
  • Collection
  • CLI
  • Other

Steps to reproduce

  1. On a new installation, as admin, create a user: POST /api/v2/users/ with {"username": "svc-ci", "password": "<any>"} (or without a password once Allow creating a local user without a password #1090 is in). Note its id.
  2. As admin: POST /api/v2/users/<svc-ci id>/personal_tokens/ with {"description": "CI", "scope": "write"}.
  3. Look at the user field of the response, and GET /api/v2/me/ with Authorization: Bearer <token>.
  4. As admin: GET /api/v2/users/<svc-ci id>/personal_tokens/.
  5. As admin: POST /api/v2/users/9999/personal_tokens/ (no such user).
  6. Add svc-ci to the Default organization, create a second user orgadmin and make it that organization's admin. As orgadmin: POST /api/v2/users/<svc-ci id>/personal_tokens/.

Current results

  • Step 2 returns 201. The response's user is the admin's id, and in step 3 the token authenticates as admin, not svc-ci.
  • Step 4 lists the admin's own personal tokens, not svc-ci's.
  • Step 5 returns 201 and creates another token for admin.
  • Step 6 returns 201 and creates a token for orgadmin. The same goes for a system auditor, and for any user who can see the target. The documentation's example (docs/auth/oauth.md) uses the literal URL /api/v2/users/1/personal_tokens/, and every user can see user 1 (system administrators are always visible), so whoever follows it gets their own token regardless.
  • A requester who can't see the target user gets 403 for both POST and GET, so the misleading 201 happens exactly when the requester can see the user named in the URL.
  • The activity-stream entry for the token names only the actor; changes has no owner, because user is read-only in the token serializer.

Sugested feature result

POST /api/v2/users/<id>/personal_tokens/ creates a token owned by <id>:

Requester Today Proposed
<id> themselves 201, own token unchanged
superuser, another user 201, superuser's own token 201, token owned by <id>
superuser, inactive user 201, superuser's own token 400 "Cannot create a token for an inactive user."
superuser, local user while DISABLE_LOCAL_AUTH is on 201, superuser's own token 400 "Cannot create a token for a local user while local authentication is disabled."
superuser, no such user 201, superuser's own token 404
anyone else (org admin, system auditor, normal user) who can see <id> 201, own token 403
anyone who can't see <id> 403 403
  • The external-account check (ALLOW_OAUTH2_FOR_EXTERNAL_USERS) applies to the owner, so a superuser can't create a token for an LDAP/SAML/RADIUS user when that setting forbids those users from having one (403, the existing message).
  • "Local" in the DISABLE_LOCAL_AUTH case is the same test DisableLocalAuthMiddleware uses (no LDAP DN, social or enterprise auth). Turning that setting on revokes those users' tokens, so issuing new ones afterward would undo it.
  • GET /api/v2/users/<id>/personal_tokens/ lists <id>'s personal tokens, limited to what the requester may read under OAuth2TokenAccess: superusers and system auditors see them, <id> sees their own, anyone else who can see <id> gets an empty list.
  • The activity-stream entry for a token whose owner isn't the actor names both: actor is the administrator, changes.user is the owner ("svc-ci (7)"), and the entry also appears in the owner's /api/v2/users/<id>/activity_stream/.

Why superusers only, rather than anyone who can change the user: UserAccess.can_change/can_admin let an organization admin edit a member, but OAuth2TokenAccess limits reading, changing and revoking a personal token to its owner and superusers. If org admins could issue a token for a member, they couldn't list or revoke it afterward. Extending this to org admins would mean widening personal-token read and delete for them at the same time, which is a bigger change. The access check is a single condition in OAuth2TokenAccess.can_add, so it's easy to widen later if you want that.

Additional information

Prior art upstream: ansible/awx#1653 (2018, "Allow Token Creation for other Users in the REST API", still open) asks for the same thing and mentions create_oauth2_token as the workaround. ansible/awx#15159 (2024, open) reports the GET half. AWX then removed its OAuth2 provider (ansible/awx#15666), so neither will be fixed there. django-ansible-base's replacement makes the user's personal_tokens sub-URL read-only and filters it by the user in the URL, without adding issuance.

Compatibility: this changes what an existing endpoint returns.

  • A superuser who posts to another user's URL now gets a token owned by that user instead of themselves. A script that hard-codes another id (for example the documentation's /api/v2/users/1/) and runs as a different superuser would now get a token for user 1. The response's user field shows the owner, and the attached PR updates the documentation's example to <user_id>.
  • A non-superuser posting to someone else's URL gets 403 instead of 201 and their own token. A superuser posting to a nonexistent id gets 404.
  • The same 403 applies to a non-superuser's POST to someone else's /api/v2/users/<id>/tokens/ and /authorized_tokens/. Those views share the permission check, which asks OAuth2TokenAccess.can_add about the user in the URL. Superusers posting there still get a token of their own, as today.
  • GET on another user's URL returns that user's tokens (superusers, auditors) or an empty list, instead of the requester's own.
  • Unchanged: /api/v2/tokens/ (which the UI and ctrliq.ascender use for personal tokens) and the user's own /tokens/ and /authorized_tokens/ still create tokens for the requester. The in-repo UI doesn't call personal_tokens/ and shows the Tokens tab only on your own user page, so it is unaffected. The OpenAPI schema doesn't change.
  • Activity stream: token-create entries whose owner isn't the actor gain changes.user and the owner link. That includes tokens created with no request user, such as by create_oauth2_token, whose entries name nobody today. Entries for tokens users create for themselves are unchanged.

Not covered: a superuser's POST to another user's /api/v2/users/<id>/tokens/ or /authorized_tokens/ still creates the superuser's own token, and GET .../authorized_tokens/ lists the requester's tokens. The same approach would work for both; I left them out to keep this to the personal-token case. Token deletion entries still don't name the owner.

I have a PR ready that implements this, with tests.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions