Skip to content

feat: separate deactivating a user from deleting them, and manage administrators - #19

Merged
hujincalrin41 merged 4 commits into
mainfrom
feature/user-lifecycle
Sep 24, 2026
Merged

hujincalrin41 merged 4 commits into
mainfrom
feature/user-lifecycle

Conversation

@hujincalrin41

Copy link
Copy Markdown
Contributor

Closes #18.

What this does

Operation Behaviour
Deactivate Reversible. Grants, owned objects and the email are all left alone, so reactivating gives the account back as it was. Access stops on the next request, since the state is re-read per request.
Purge Irreversible, and the only thing that frees the email for reuse.
Administrators Appoint and stand down metastore administrators from the Users page, under the invariant that at least one enabled administrator always remains.

Purge is guarded in the service rather than trusted to the caller: the principal has to be typed
back (the user list mixes people with instance principals, where the wrong row takes a whole
compute instance offline), the account must already be deactivated, you cannot purge your own or
the bootstrap administrator, and a user who owns securables cannot be purged without naming an
heir — those objects change hands as part of the operation.

Fixed along the way

Problem Origin
PATCH /scim2/Users/{id} carried no authorization annotation, and UnityAccessDecorator lets a method without one through — so any signed-in account could deactivate any other, an administrator included upstream #500
The administrator password sign-in never consulted the user table, so a deactivated admin still got a session whose every request was then refused — a sign-in that looked like it worked and did nothing #17
The bootstrap administrator could be deactivated by three separate routes (PATCH, DELETE, PUT) and stripped of its administrator status. Either closes the password sign-in, which is the way back in when the identity provider is unavailable, and no restart repairs the lost privilege this series + upstream
The PatchOp schema typed value as a boolean, so the UI sent a scalar where RFC 7644 §3.5.2.1 requires an object; the SCIM library rejected it while parsing and the user saw a JSON parse failure instead of a deactivated account this series
Test servers raced for their port — picked, released, then only bound once Hibernate was up — failing whole test classes with Address already in use on a loaded runner pre-existing

The last one is why test (Spark 4.1.0) went red on main right after #17 merged:
AuthServiceIdentityResolutionTest failed 1 of 224 in setUp, for a reason that had nothing to do
with that class.

On the bootstrap administrator: UnityAccessUtil#initializeAdmin deliberately allows a deployment
to deactivate it once real accounts exist. That note predates the password sign-in added in #17,
which gave the same account a second job; the two cannot both hold, and the way back in wins.
Granting that account extra privileges is untouched — it locks nobody out.

Tests

server 231 → 252, ui 100 → 109. New: Scim2UserPatchTest, Scim2UserPurgeTest,
MetastoreAdminsTest, BootstrapAdminProtectionTest, users.test.tsx, UsersList.test.tsx, plus
additions to AuthServiceTokenLoginTest and UserActionsDropdown.test.tsx.

Every fix here has a test that was verified in reverse: reverting the fix makes the corresponding
case fail.

hujincalrin and others added 4 commits September 24, 2026 07:30
…inistrators

Closes #18.

`DELETE /scim2/Users/{id}` cleared every grant the principal held and then only marked the account
DISABLED: advertised as deletion, behaving as deactivation, and destroying the grants on the way.
The row survived, so the email stayed taken and could never be registered again.

The two are now separate operations:

- Deactivate leaves grants and owned objects untouched, so reactivating gives the account back as
  it was. Access stops on the next request, since the state is re-read per request.
- Purge is the irreversible one, and the only thing that frees the email. It requires the principal
  typed back, refuses an account that is still active, refuses your own, and requires an heir when
  the user owns securables -- those objects change hands.

Metastore administrators can be appointed and stood down from the Users page, under the invariant
that at least one *enabled* administrator always remains.

Fixed along the way:

- `PATCH /scim2/Users/{id}` carried no authorization annotation (from upstream #500), and the
  access decorator lets a method without one through, so any signed-in account could deactivate
  any other -- an administrator included. It now requires metastore OWNER.
- The administrator password sign-in never consulted the user table: a deactivated `admin` still
  received a session, whose every request was then refused. It now checks that the account exists
  and is enabled, and says which is wrong.
- The bootstrap administrator could be deactivated by three separate routes, and stripped of its
  administrator status, either of which closes the password sign-in -- the way back in when the
  identity provider is unavailable, and something no restart repairs. All of them are refused.
- The PatchOp schema typed `value` as a boolean, so the UI sent a scalar where RFC 7644 3.5.2.1
  requires an object; the SCIM library rejected it while parsing and the user saw a JSON parse
  failure instead of a deactivated account. Both forms the RFC allows are now accepted.
- Test servers raced for their port: it was picked, released, and only bound once Hibernate was up,
  failing whole test classes with `Address already in use` on a loaded runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `purge` / `reassign_to` / `confirm_principal` parameters changed `UsersApi.deleteUser`, and the
CLI still called the one-argument form, so the `cli` module did not compile. Passing nulls keeps
what `uc user delete` has always done -- deactivate.

It went unnoticed because the GitLab pipeline builds and tests `server` alone; GitHub builds every
module, which is where it surfaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The port retry added alongside this series builds a new server and binds it, but a start that
failed partway was left as it was. Anything it had already bound would keep listening, and keep
answering requests meant for the server the test actually ends up using.

Stop it before retrying. Nothing in CI has taken this path yet -- the retry has not fired since it
landed -- which is the point: it should not be the first thing to go wrong when it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hujincalrin41
hujincalrin41 merged commit 8bc0981 into main Sep 24, 2026
13 checks passed
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.

DELETE on a SCIM user clears grants irreversibly but only deactivates the account

2 participants