Skip to content

fix(org:user): report missing organization permissions plainly - #136

Open
vrobert78 wants to merge 2 commits into
mainfrom
fix/org-user-permission-message
Open

fix(org:user): report missing organization permissions plainly#136
vrobert78 wants to merge 2 commits into
mainfrom
fix/org-user-permission-message

Conversation

@vrobert78

Copy link
Copy Markdown

Problem

A user with no permission on an organization, but with an admin role on one of its projects, gets a raw API error from org:user:add, org:user:update and org:user:delete:

$ upsun org:user:update -o vincent-psh

In EventSubscriber.php line 80:
  Permission denied. Check your permissions on the organization.

In ApiResponseException.php line 78:
  Client error: `GET https://api.upsun.com/organizations/…/members?page%5Bsize%5D=100` resulted in a `403 Forbidden` response:
  {"status":403,"title":"Forbidden","detail":"You do not have access to view this organization's members."}
   [detail] You do not have access to view this organization's members. [title] Forbidden

upsun organization:user:update [-o|--org ORG] [--permission PERMISSION] …
$ upsun org:user:add -o vincent-psh
Enter the email address of a user to add: test@example.com

In ApiResourceBase.php line 440:
  Link not found: members

upsun organization:user:add [-o|--org ORG] [--permission PERMISSION] …

org:user:list gets this right already:

$ upsun org:user:list -o vincent-psh
You do not have permission to view users in the organization Vincent-PSH (vincent-psh).

Cause

org:user:list, org:user:get and org:user:projects check the organization's members link right after selecting it. The three write commands never got that check.

The link passed to the selector ('create-member') does not cover it: Selector::selectOrganization() only applies $filterByLink when it picks an organization itself — automatically from the current project, or interactively. An explicit --org returns the organization as is (Selector.php:909-931). The commands then went on to list members either through Api::loadMemberByEmail(), which resolves the members link and throws Link not found: members, or through chooseMember(), which builds the members URL from the organization URI and so bypasses the links entirely and takes a bare 403.

Fix

Check the links these commands actually dereference, immediately after selecting the organization, and word the message like the read-only commands:

You do not have permission to add users to the organization Vincent-PSH (vincent-psh).
You do not have permission to update users in the organization Vincent-PSH (vincent-psh).
You do not have permission to delete users from the organization Vincent-PSH (vincent-psh).

The guard fires only when a link the command would dereference is missing — in which case the command was already guaranteed to fail — so it cannot block an operation that used to succeed. Adding a user additionally needs the invitations link, which Organization::inviteMemberByEmail() resolves.

Verification

integration-tests/org_user_permission_test.go drives the real binary against the mock API, with an organization whose _links hold only self, and the members endpoint returning the 403 body above:

  • Against unmodified main the new tests reproduce both failures verbatim (Link not found: members plus the usage dump), while the org:user:list and org:user:get cases pass — a check on the existing behaviour the new messages are modelled on.
  • With the fix all cases pass, including org:user:update -o <org> run interactively with no email, which is the path that went through chooseMember() and took the bare 403. The test also asserts the members endpoint is never requested.
  • TestOrgUserWithPermission is the counterpart: with members and invitations present, the commands get past the check and reach their normal "user not found" behaviour.

Also confirmed against the production API with a real account, on an organization where the user is only a project admin:

root@8b9da64a53ad:~# upsun org:user:update -o vincent-psh
You do not have permission to update users in the organization Vincent-PSH (vincent-psh).
root@8b9da64a53ad:~# upsun org:user:add -o vincent-psh
You do not have permission to add users to the organization Vincent-PSH (vincent-psh).

Full integration suite green; php-cs-fixer and PHPStan (PHP 8.4) clean.

Left alone, for a possible follow-up

  • A user with organization read access but not write still ends on Permission denied. Check your permissions on the organization. followed by the chained ApiResponseException, because Symfony renders the whole getPrevious() chain. That affects every 4xx in the CLI, not just these commands.
  • Selector::selectOrganization() still ignores $filterByLink when --org is explicit, so the same class of bug can reappear in a new command. Enforcing it there would prevent that, at the cost of a generic message in place of the per-command wording.

🤖 Generated with Claude Code

A user with no permission on an organization, but with an admin role on one
of its projects, got a raw API error from org:user:add, org:user:update and
org:user:delete:

    $ upsun org:user:add -o an-org
    In ApiResourceBase.php line 440:
      Link not found: members

    $ upsun org:user:update -o an-org
    In EventSubscriber.php line 80:
      Permission denied. Check your permissions on the organization.
    In ApiResponseException.php line 78:
      Client error: `GET .../members?page%5Bsize%5D=100` resulted in a `403 Forbidden` ...

org:user:list, org:user:get and org:user:projects handle this: they check the
organization's 'members' link after selecting it and report the problem in one
line. The three write commands never got that check.

The link the selector is given ('create-member') is only applied when the
selector picks an organization itself, either automatically or interactively;
an explicit --org returns the organization as is. So the commands went on to
list members, either through Api::loadMemberByEmail(), which resolves the
'members' link, or through chooseMember(), which builds the members URL from
the organization URL and so bypasses the links entirely and gets a bare 403.

Check the links these commands actually dereference right after selecting the
organization, wording the message like the read-only commands:

    You do not have permission to add users to the organization An Org (an-org).
    You do not have permission to update users in the organization An Org (an-org).
    You do not have permission to delete users from the organization An Org (an-org).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 16:37

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.

Pull request overview

This PR improves the legacy (PHP) organization user write commands by detecting missing organization-level permissions earlier and emitting a clear, user-facing message (matching the existing read-only command wording) instead of surfacing raw API/HAL-link failures. It also adds integration coverage to ensure these commands no longer hit the members endpoint when the organization’s links indicate the user lacks access.

Changes:

  • Add explicit post-selection link guards to org:user:add, org:user:update, and org:user:delete to fail fast with plain permission messages when required organization links are missing.
  • Add integration tests that reproduce the prior failures and assert the new behavior (including the interactive update path).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
legacy/src/Command/Organization/User/OrganizationUserDeleteCommand.php Adds a members-link guard after organization selection to avoid raw API errors and print a clear “no permission to delete users” message.
legacy/src/Command/Organization/User/OrganizationUserAddCommand.php Adds members / invitations link guards (depending on add vs update) to fail early with clear “no permission” messaging.
integration-tests/org_user_permission_test.go Adds integration tests covering no-permission vs with-permission cases across org user commands, including interactive update behavior.
Comments suppressed due to low confidence (1)

integration-tests/org_user_permission_test.go:113

  • After switching to a synchronized membersRequested flag, the final assertion should also use a synchronized read (e.g., non-blocking receive) instead of reading a shared variable directly.
	assert.False(t, membersRequested, "the members endpoint must not be requested without the link")

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread integration-tests/org_user_permission_test.go Outdated
The flag is written from the test server's handler goroutine and read from
the test, which is unsynchronized. The race detector does not report it —
in the passing case the handler is never called at all, which is what the
assertion checks — but an atomic makes a regression fail on the assertion
rather than on goroutine timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants