PATCH /subjects/:id
Requires manage-users. Amends email and enabled — and username,
where the tenant allows it (below) — the only general fields a subject
exposes; everything else about a subject (credentials, required actions,
roles) has its own door below. An email another subject in the tenant
already holds is refused with 409, as on POST /subjects. Honours
If-Match, answering 412 on a mismatch, the same convention every other
amendment in this API follows — locked with SELECT … FOR NO KEY UPDATE
before the ETag is computed, so two concurrent amendments cannot both
pass the precondition. NO KEY UPDATE rather than UPDATE, as on every
admin route that mutates a subject: it still queues a second admin
mutation, but not a sign-in inserting a row that names the subject, which
takes a key-share lock and would otherwise deadlock against it. Held to the target ceiling
(POST /subjects/:id/password), as every route that
mutates a subject is: refused with 403 when the subject holds an admin
capability the caller does not.
curl -sS -D - -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": false}' \
http://localhost:3000/admin/tenants/demo/subjects/01a0d6fd-9453-7bf0-9823-a5fc6ea34836
HTTP/1.1 200 OK
etag: "e0aa2da695d68cf78750779f56f4003957282bd6a88ddab5b3aa117e2efa7a19"
content-type: application/json; charset=utf-8
{"id":"01a0d6fd-9453-7bf0-9823-a5fc6ea34836","type":"user","username":"ada","email":"ada@demo.example","enabled":false,"created_at":"2026-09-25T05:15:37.939Z"}
(Not re-run for the cache-control: no-store pass: same reason as the
read above.)
The ETag is the one GET /subjects/:id above returned, recomputed — a
caller that read before this write holds a stale one.
Renaming a username
A username is not an identifier a relying party may key on — sub is
(ADR 0039) —
so it is renamable, through this same PATCH, behind the tenant setting
username_editable, false by default. With it off, username in the body
is refused with 400 naming the setting. With it on, the new name is held to
the rule creation holds one to (a non-empty string, usernameSchema in
@odudu/contracts), and If-Match is mandatory whenever username is
in the body — 428 without it, the precedent redirect_uris sets on
PATCH /clients/{id} — because one administrator's rename silently undoing
another's is what the precondition exists to stop. A name another subject
in the tenant holds refuses the whole request with 409: the unique index
users_username_unique answers, outside the transaction, so nothing else
the same body asked for is applied either. That index is on
(tenant_id, username) and case-sensitive, so a case-variant of another
subject's name (Ada beside ada) is a distinct name and is accepted — as
it is on POST /subjects and at sign-in, which match a username exactly. A
rename to the name the subject already has, with nothing else in the body,
writes nothing and no audit row. An actual rename writes one
subject.amend row whose detail carries the before and after values.
Nothing keyed on the subject moves: sessions and refresh tokens hold sub,
so both keep working; the brute-force counter is keyed by subject rather
than by the name submitted, so a login_failures row survives the rename;
and preferred_username (and name, when no display name is set) carries
the new name on the next token issued. The old name stops signing in at
once. A password equal to the new username is not re-checked against
password_not_username until it is next changed, which is when every
password policy applies.
As a new system admin ada-rename (seed admin), in a tenant rename-demo
created for it through POST /admin/tenants, on a subject grace seeded with a
real password (odudu seed user), a second subject ada created through POST /subjects, and a confidential client rename-demo-app created through POST /clients with https://app.example/callback registered and the
authorization_code and refresh_token grants; $CLIENT_SECRET is the secret
that create returned. $GRACE is 01a0e37c-9f28-77e3-8579-d0907e90b1c3, and
signin is the helper DELETE /subjects/:id/lockout defines, pointed at this
client with profile in the scope and taking the username as its argument:
AUTHORIZE='http://localhost:3000/tenants/rename-demo/protocol/openid-connect/auth?response_type=code&client_id=rename-demo-app&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&scope=openid%20profile&state=xyz&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256'
signin() {
sid=$(curl -sS "$AUTHORIZE" | sed -n 's/.*name="auth_session_id" value="\([^"]*\)".*/\1/p' | head -1)
curl -sS -D - -o body.html \
--data-urlencode "auth_session_id=$sid" \
--data-urlencode "username=$1" \
--data-urlencode "password=correct horse battery staple" \
http://localhost:3000/tenants/rename-demo/login-actions/authenticate | grep -iE '^(HTTP|location)'
grep -o '<title>[^<]*</title>' body.html || true
}
With the setting still at its default, the rename is refused. This one refusal
is from a separate run, in a tenant also named rename-demo created for it, on
its own grace (01a0ea54-d24b-7445-9226-9123b58e9e4e); the rest of this
section is from the first:
curl -sS -D - -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: *' \
-d '{"username": "grace-hopper"}' \
http://localhost:3080/admin/tenants/rename-demo/subjects/$GRACE
HTTP/1.1 400 Bad Request
x-request-id: 01a0ea54-d25b-70fa-baab-fa89b03dea50
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 301
Date: Mon, 28 Sep 2026 23:23:42 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"type":"about:blank","title":"Bad Request","status":400,"detail":"username: this tenant has not enabled username editing (username_editable)","errors":[{"path":"username","message":"this tenant has not enabled username editing (username_editable)"}],"instance":"01a0ea54-d25b-70fa-baab-fa89b03dea50"}
Turning it on — PATCH /settings reaches it too — then signing grace
in and redeeming the code, keeping the refresh token:
docker compose exec -T odudu node dist/main.js seed tenant --name rename-demo --set username_editable=true 2>/dev/null
loc=$(signin grace | tee /dev/stderr | tr -d '\r' | sed -n 's/^location: //p')
code=$(echo "$loc" | sed -n 's/.*code=\([^&]*\).*/\1/p')
REFRESH_TOKEN=$(curl -sS -u "rename-demo-app:$CLIENT_SECRET" \
--data-urlencode grant_type=authorization_code --data-urlencode "code=$code" \
--data-urlencode redirect_uri=https://app.example/callback \
--data-urlencode code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \
http://localhost:3000/tenants/rename-demo/protocol/openid-connect/token | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
echo "refresh token length ${#REFRESH_TOKEN}"
{"command":"tenant","created":false,"tenant":"rename-demo","tenantId":"01a0e37c-9ce6-79d3-a088-ba646b0b9917","settings":["username_editable"]}
HTTP/1.1 302 Found
location: https://app.example/callback?code=u7NfghfY65Cv_CMaF2GmK_AibKzovcN84Y0Lz1Nvb7k&state=xyz&iss=http%3A%2F%2Flocalhost%3A3000%2Ftenants%2Frename-demo
refresh token length 43
Without If-Match, the rename is refused and nothing changes:
curl -sS -D - -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username": "grace-hopper"}' \
http://localhost:3000/admin/tenants/rename-demo/subjects/$GRACE
HTTP/1.1 428 Precondition Required
x-request-id: 01a0e37d-0d3d-71b8-b54b-58bf801044f4
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 181
Date: Sun, 27 Sep 2026 15:30:18 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"type":"about:blank","title":"Precondition Required","status":428,"detail":"If-Match is required to replace a subject's username","instance":"01a0e37d-0d3d-71b8-b54b-58bf801044f4"}
Reading the subject for its ETag, then renaming under it:
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3000/admin/tenants/rename-demo/subjects/$GRACE
curl -sS -D - -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "07c46668d99ac4e0d32d58908d78d5d04ea89d0eaf03470bdd5662d830f63c08"' \
-d '{"username": "grace-hopper"}' \
http://localhost:3000/admin/tenants/rename-demo/subjects/$GRACE
HTTP/1.1 200 OK
x-request-id: 01a0e37d-0d59-7a31-813e-162ee1bb2c3d
cache-control: no-store
etag: "07c46668d99ac4e0d32d58908d78d5d04ea89d0eaf03470bdd5662d830f63c08"
content-type: application/json; charset=utf-8
content-length: 146
Date: Sun, 27 Sep 2026 15:30:18 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"id":"01a0e37c-9f28-77e3-8579-d0907e90b1c3","type":"user","username":"grace","email":null,"enabled":true,"created_at":"2026-09-27T15:29:50.369Z"}
HTTP/1.1 200 OK
x-request-id: 01a0e37d-0d71-7b2c-8918-ddf9dca2a612
cache-control: no-store
etag: "dc0ac68013546a9ca613220ddfc34b73e5d99e8b681c651bcecb0a6d22c07d81"
content-type: application/json; charset=utf-8
content-length: 153
Date: Sun, 27 Sep 2026 15:30:18 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"id":"01a0e37c-9f28-77e3-8579-d0907e90b1c3","type":"user","username":"grace-hopper","email":null,"enabled":true,"created_at":"2026-09-27T15:29:50.369Z"}
The refresh token grace held before the rename still redeems, and the
access token it buys reads the new name back from /userinfo:
ACCESS_TOKEN=$(curl -sS -u "rename-demo-app:$CLIENT_SECRET" \
--data-urlencode grant_type=refresh_token \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
http://localhost:3000/tenants/rename-demo/protocol/openid-connect/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "access token length ${#ACCESS_TOKEN}"
curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:3000/tenants/rename-demo/protocol/openid-connect/userinfo
access token length 691
{"sub":"01a0e37c-9f28-77e3-8579-d0907e90b1c3","name":"grace-hopper","preferred_username":"grace-hopper"}
The old name no longer signs in with the right password; the new one does:
signin grace
signin grace-hopper
HTTP/1.1 200 OK
<title>Sign in</title>
HTTP/1.1 302 Found
location: https://app.example/callback?code=tVn0xzcPENRQ4yAfzhtOoiiJ85cWXohi4NgdgtE2eAQ&state=xyz&iss=http%3A%2F%2Flocalhost%3A3000%2Ftenants%2Frename-demo
Renaming to ada, which the other subject holds, with an email change in
the same body — refused, and a read afterwards shows neither applied and
the ETag unchanged:
curl -sS -D - -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "dc0ac68013546a9ca613220ddfc34b73e5d99e8b681c651bcecb0a6d22c07d81"' \
-d '{"username": "ada", "email": "grace@rename.example"}' \
http://localhost:3000/admin/tenants/rename-demo/subjects/$GRACE
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3000/admin/tenants/rename-demo/subjects/$GRACE
HTTP/1.1 409 Conflict
x-request-id: 01a0e37d-0e85-76d4-a49e-833f42b3e389
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 154
Date: Sun, 27 Sep 2026 15:30:18 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"type":"about:blank","title":"Conflict","status":409,"detail":"the username \"ada\" is already in use","instance":"01a0e37d-0e85-76d4-a49e-833f42b3e389"}
HTTP/1.1 200 OK
x-request-id: 01a0e37d-0eb3-7a56-bfc2-68761f0d90ba
cache-control: no-store
etag: "dc0ac68013546a9ca613220ddfc34b73e5d99e8b681c651bcecb0a6d22c07d81"
content-type: application/json; charset=utf-8
content-length: 153
Date: Sun, 27 Sep 2026 15:30:18 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"id":"01a0e37c-9f28-77e3-8579-d0907e90b1c3","type":"user","username":"grace-hopper","email":null,"enabled":true,"created_at":"2026-09-27T15:29:50.369Z"}
Ada, a case-variant of that same name, is a different name to
users_username_unique, and is accepted under the same ETag:
curl -sS -D - -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "dc0ac68013546a9ca613220ddfc34b73e5d99e8b681c651bcecb0a6d22c07d81"' \
-d '{"username": "Ada"}' \
http://localhost:3000/admin/tenants/rename-demo/subjects/$GRACE
HTTP/1.1 200 OK
x-request-id: 01a0e37d-0ecc-7554-bf0c-c15ec0474da2
cache-control: no-store
etag: "59790dd68519f35537ae76289a644b4caf1ebbcd47c933830b8f719b7b1c4501"
content-type: application/json; charset=utf-8
content-length: 144
Date: Sun, 27 Sep 2026 15:30:18 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"id":"01a0e37c-9f28-77e3-8579-d0907e90b1c3","type":"user","username":"Ada","email":null,"enabled":true,"created_at":"2026-09-27T15:29:50.369Z"}
The audit trail for grace holds the two renames and nothing for the
refused ones — the 409 rolled back with its transaction, and the 400
and 428 were refused before anything was written:
The same again, now that each row answers actor_name and actor_origin; the
ids are a fresh run's, not those above:
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/rename-demo/audit?resource_type=subject&resource_id=$GRACE"; echo
{"items":[{"id":"01a0ee8f-5fb5-792d-b8a6-34c2673e2c04","occurred_at":"2026-09-29T19:06:08.688Z","event_type":"admin_mutation","action":"subject.amend","outcome":"allowed","actor_tenant_id":"0199aa00-0000-7000-8000-000000000001","actor_subject_id":"01a0ee8a-bfb8-763c-ac22-0c8d97b0fada","actor_client_id":"01a0ee8a-bf72-77c9-a423-f61084924d9f","actor_name":null,"actor_origin":"system","resource_type":"subject","resource_id":"01a0ee8f-5d26-7a80-b7c9-3c64c4f582cf","request_id":"01a0ee8f-5fa4-7964-85ce-e82236b46ff3","ip":"172.22.0.1","detail":{"username":{"after":"Ada","before":"grace-hopper"}}},{"id":"01a0ee8f-5f83-702a-858a-e775655db38b","occurred_at":"2026-09-29T19:06:08.639Z","event_type":"admin_mutation","action":"subject.amend","outcome":"allowed","actor_tenant_id":"0199aa00-0000-7000-8000-000000000001","actor_subject_id":"01a0ee8a-bfb8-763c-ac22-0c8d97b0fada","actor_client_id":"01a0ee8a-bf72-77c9-a423-f61084924d9f","actor_name":null,"actor_origin":"system","resource_type":"subject","resource_id":"01a0ee8f-5d26-7a80-b7c9-3c64c4f582cf","request_id":"01a0ee8f-5f73-7961-bb79-d4978fb330e7","ip":"172.22.0.1","detail":{"username":{"after":"grace-hopper","before":"grace"}}}]}