Admin API
The Admin API provides HTTP endpoints for managing users, credentials, and system information. All endpoints (except login) require a JWT token with SuperUser role.
Authentication
First, obtain a JWT token:
TOKEN=$(curl -s -k -X POST https://localhost:9000/api/admin/login \
-H 'Content-Type: application/json' \
-d '{"username":"root","password":"password12345"}' | jq -r '.token')
Use the token in subsequent requests:
curl -s -k https://localhost:9000/api/admin/users \
-H "Authorization: Bearer $TOKEN"
Endpoints
Login
POST /api/admin/login
No authentication required.
curl -s -k -X POST https://localhost:9000/api/admin/login \
-H 'Content-Type: application/json' \
-d '{"username":"root","password":"password12345"}'
Response:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": "2026-02-11T10:00:00Z"
}
A wrong password and an unknown account both answer 401 with code
InvalidCredentials — identical responses, so the endpoint cannot be used to
find out which account names exist.
expires_at is the token's expiry, S4_JWT_LIFETIME_HOURS after the login (24 hours by default).
A token works only while its account keeps the password, role and active status it had at login —
see Sessions.
Logout
POST /api/admin/logout
Any account, not only SuperUser. Ends every session of the caller's account, the one of this token included.
curl -s -k -X POST https://localhost:9000/api/admin/logout \
-H "Authorization: Bearer $TOKEN"
Response: 204 No Content
List Users
GET /api/admin/users
curl -s -k "https://localhost:9000/api/admin/users?max-keys=100" \
-H "Authorization: Bearer $TOKEN"
Response: {"users": [...], "is_truncated": true, "next_continuation_token": "..."} — root
first, then the users in the order of their ids; password hashes and secret keys are never
returned. Without max-keys every user is in one answer. With max-keys (1 to 1000) the list
comes a page at a time: while is_truncated is true, ask again with
continuation-token=<next_continuation_token>.
Create User
POST /api/admin/users
curl -s -k -X POST https://localhost:9000/api/admin/users \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice123","role":"Writer"}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
username |
string | Yes | 3-32 characters, alphanumeric and underscore |
password |
string | Yes | User password |
role |
string | Yes | Reader, Writer, or SuperUser |
Get User
GET /api/admin/users/{id}
curl -s -k https://localhost:9000/api/admin/users/<user-id> \
-H "Authorization: Bearer $TOKEN"
Update User
PUT /api/admin/users/{id}
curl -s -k -X PUT https://localhost:9000/api/admin/users/<user-id> \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"role":"Reader"}'
Request body (all fields optional):
| Field | Type | Description |
|---|---|---|
password |
string | New password |
role |
string | New role (Reader, Writer, SuperUser) |
is_active |
boolean | Enable or disable the account |
Delete User
DELETE /api/admin/users/{id}
curl -s -k -X DELETE https://localhost:9000/api/admin/users/<user-id> \
-H "Authorization: Bearer $TOKEN"
Response: 204 No Content
Generate S3 Credentials
POST /api/admin/users/{id}/credentials
curl -s -k -X POST https://localhost:9000/api/admin/users/<user-id>/credentials \
-H "Authorization: Bearer $TOKEN"
Response:
{
"access_key": "S4AKxxxxxxxxxxxxxxxxxxxx",
"secret_key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
Important: The secret_key is shown only once. Store it securely — it cannot be retrieved again.
Delete S3 Credentials
DELETE /api/admin/users/{id}/credentials
curl -s -k -X DELETE https://localhost:9000/api/admin/users/<user-id>/credentials \
-H "Authorization: Bearer $TOKEN"
Response: 204 No Content
Revoke Sessions
POST /api/admin/users/{id}/sessions/revoke
Ends every session of the user: tokens issued before the call answer 401. The password, role and
S3 credentials stay as they are; the user logs in again for a new token. Works for root too.
curl -s -k -X POST https://localhost:9000/api/admin/users/<user-id>/sessions/revoke \
-H "Authorization: Bearer $TOKEN"
Response: 204 No Content
IAM Adoption Report
GET /api/admin/iam/adoption
Cluster mode: what each node keeps of its own after an upgrade from an earlier release — IAM users created through it alone — for you to decide on (see Upgrading a cluster). Every node of the pool is asked.
curl -s -k https://localhost:9000/api/admin/iam/adoption -H "Authorization: Bearer $TOKEN"
Response:
{
"mode": "cluster",
"state": "pending",
"changes_open": false,
"node": "node-1",
"report_id": "4b8f0c2e9a1d7e3f5c6b2a9d0e1f3a4b",
"complete": true,
"pool_size": 3,
"majority": 2,
"nodes": [
{ "node_id": "…", "node_name": "node-1", "answered": true, "own_users": 2, "digest": "…" }
],
"users": [
{
"username": "bob",
"nodes": ["node-1", "node-2"],
"on_majority": true,
"conflicts": ["ids", "roles"],
"records": [
{ "id": "…", "copies": [ { "node": "node-1", "role": "Writer", "is_active": true,
"created_at": "…", "updated_at": "…", "access_key": "S4AK…" } ] }
]
}
],
"conflicts": 1,
"not_on_majority": 0,
"decision": null
}
state is adopted once IAM changes are open on this node, pending while they are not.
complete is false when a node did not answer: its error says why. No secret key or password
hash is reported. decision is the last decision taken, when there is one. A single node answers
"mode": "standalone", "state": "adopted".
Take Over IAM
POST /api/admin/iam/adoption
Takes over the users the nodes keep of their own, as decided on the report named by report_id.
curl -s -k -X POST https://localhost:9000/api/admin/iam/adoption \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"report_id": "4b8f0c2e9a1d7e3f5c6b2a9d0e1f3a4b", "mode": "majority"}'
mode |
Takes over |
|---|---|
majority |
The users whose name a majority of nodes holds |
union |
Every user some node holds |
users |
Exactly the users listed by id in "users": ["<id>", …] |
Of several users of one name the one created first keeps it; each other is renamed
<name>~<first 8 characters of its id> and disabled. Users left out are dropped.
Response: the users taken over (adopted, each with renamed_from when renamed), those left
out (left_out), and those the cluster held already (already_adopted, from a decision repeated
after one stopped half way). IAM changes open once every node has dropped what it kept.
IAM Audit Log
GET /api/admin/iam/audit
Every change of IAM — a user created, changed or deleted, S3 credentials issued or deleted, sessions revoked, root's sessions ended, the cluster taking over the users of an earlier release, secrets encrypted again with a new key — in the order the changes were made. In cluster mode every node holds the whole log.
curl -s -k "https://localhost:9000/api/admin/iam/audit?max-keys=100" \
-H "Authorization: Bearer $TOKEN"
Response:
{
"entries": [
{
"id": "00000000000000000042_00001791504000123456_9f3a01bc",
"at": "2026-10-09T10:00:00.123456Z",
"actor": { "id": "8c1d…", "name": "root" },
"action": "user_updated",
"target_id": "5b2e…",
"target": "alice",
"through": "node-2",
"epoch": 42,
"details": { "role": "Writer -> Reader", "password": "changed" }
}
],
"is_truncated": false
}
action is one of user_created, user_updated, user_deleted, credentials_issued,
credentials_deleted, sessions_revoked, root_sessions_revoked, iam_adopted,
secrets_resealed, audit_purged. through is the node the change went through, epoch the
cluster's turn it was made in (0 on a single node). No password, secret key or hash of one is
ever recorded. Up to 1000 entries a page, 1000 by default; while is_truncated is true, ask again
with continuation-token=<next_continuation_token>. Entries older than
S4_IAM_AUDIT_RETENTION_DAYS (90 by default) are deleted.
IAM Status
GET /api/admin/iam/status
How IAM stands on the node that answers: whether it checks the credentials of IAM users now, how every node of the cluster stands with its copy of the users, which node holds the cluster's turn for a change, and whether IAM changes are open. The node answers from what it holds, without asking another node, so it answers while the cluster is split too. SuperUser only.
curl -s -k https://localhost:9000/api/admin/iam/status \
-H "Authorization: Bearer $TOKEN"
Response (cluster mode):
{
"mode": "cluster",
"node": "node-1",
"credentials": { "state": "serving", "confirmed_ago_ms": 180, "max_staleness_secs": 30 },
"epoch": { "wall_ms": 1791545069225, "hlc": "HLC(1791545069225.0000@…)" },
"agreeing": 2,
"majority": 2,
"nodes": [
{ "node": "node-1", "node_id": "…", "local": true, "status": "alive",
"epoch": { "wall_ms": 1791545069225, "hlc": "…" }, "epoch_behind_ms": 0, "agrees": true },
{ "node": "node-2", "node_id": "…", "local": false, "status": "alive",
"epoch": { "wall_ms": 1791545069225, "hlc": "…" }, "epoch_behind_ms": 0, "agrees": true },
{ "node": "node-3", "node_id": "…", "local": false, "status": "dead",
"epoch": { "wall_ms": 1791545061870, "hlc": "…" }, "epoch_behind_ms": 7355, "agrees": false }
],
"lease": { "max_epoch": 583, "held": { "epoch": 584, "holder": "node-2", "remaining_ms": 4210 } },
"adoption": "adopted"
}
credentials.state—serving;startinguntil most nodes first agreed with this node's copy after it started;staleonce none has for longer thanS4_IAM_MAX_STALENESS_SECS. In the last two the node answers IAM users503.confirmed_ago_msis how long ago most nodes last agreed, the values4_iam_replica_lag_secondsreports.nodes— every node of the cluster as this node sees it: alive or not, the newest change its copy holds (epoch) and whether its copyagreeswith this node's.agreesis what counts:epoch_behind_mscan stay above0on a node that misses nothing — a user deleted before that node ever held it moves the others' epoch and not its own.lease.held— the change of IAM under way, if any: the node making it and how much longer its turn lasts as this node granted it;nullwhen none is.adoption—adoptedonce IAM changes are open,pendingwhile the cluster waits for you to take over users an earlier release kept on one node.
A single node answers {"mode": "standalone", "credentials": {"state": "serving"}, "adoption":
"adopted"}.
Create Bucket
PUT /api/admin/buckets/{name}
curl -s -k -X PUT https://localhost:9000/api/admin/buckets/my-bucket \
-H "Authorization: Bearer $TOKEN"
Response:
{ "name": "my-bucket" }
The name must satisfy the same S3 rules the S3 API enforces (3–63 characters,
lowercase letters, digits, hyphens and dots, starting with a letter or digit and
not ending with a hyphen) — otherwise 400 InvalidBucketName. A name already in
use answers 409 BucketAlreadyExists.
Delete Bucket
DELETE /api/admin/buckets/{name}
Deletes a bucket. By default the bucket must be empty — a bucket that still
holds objects answers 409 BucketNotEmpty, and an unknown bucket answers
404 NoSuchBucket.
curl -s -k -X DELETE https://localhost:9000/api/admin/buckets/my-bucket \
-H "Authorization: Bearer $TOKEN"
Force delete — recursively deletes all objects in the bucket first:
curl -s -k -X DELETE "https://localhost:9000/api/admin/buckets/my-bucket?force=true" \
-H "Authorization: Bearer $TOKEN"
Response: 204 No Content
| Parameter | Type | Default | Description |
|---|---|---|---|
force |
bool | false |
When true, all objects are deleted before removing the bucket |
List Bucket Objects
GET /api/admin/buckets/{name}/objects
curl -s -k "https://localhost:9000/api/admin/buckets/my-bucket/objects?max-keys=100" \
-H "Authorization: Bearer $TOKEN"
Query parameters:
| Parameter | Type | Description |
|---|---|---|
prefix |
string | Filter objects by key prefix |
max-keys |
integer | Objects and directories per page: default 50, from 1 to 1000 |
continuation-token |
string | Opaque token from the previous page's next_continuation_token |
Keys below prefix that contain another / are grouped into a directory, listed
once in common_prefixes; a page that ends with a directory continues past all
of it. The answer is { "objects": [...], "common_prefixes": [...],
"is_truncated": bool, "next_continuation_token": "...", "complete": bool }.
On a cluster node the bucket is listed from every node of the pool and merged by
key, as S3 ListObjects is, and complete is false when a node did not answer;
on a single node it is always true.
An unknown bucket answers 404 NoSuchBucket; an existing bucket with nothing in
it answers 200 with an empty objects array.
Bucket Stats
GET /api/admin/bucket-stats
Returns every bucket with its number of objects and their bytes.
curl -s -k https://localhost:9000/api/admin/bucket-stats \
-H "Authorization: Bearer $TOKEN"
{
"buckets": [
{ "name": "photos", "objects_count": 1205, "storage_used_bytes": 4823110, "created_at": 1790812345000000000 }
],
"total_storage_bytes": 4823110,
"complete": true
}
storage_used_bytes is the size of the objects as clients see them, before
deduplication and replication; created_at is in nanoseconds since the epoch.
On a cluster node the numbers are the pool's, whichever node is asked: buckets
and objects are listed from every node of the pool and merged by key, so an
object counts once, at the size of its newest version, wherever its copies are.
complete is false when a node of the pool did not answer: objects only that
node holds are then missing from the numbers. On a single node it is always
true. Every bucket is listed in full, page by page, so the request takes
longer the more objects the buckets hold.
Error Responses
Every failure returns JSON with a human-readable error and a stable,
machine-readable code. Branch on code, not on the message text:
{ "error": "Bucket 'my-bucket' is not empty. Delete its objects first, or repeat the request with ?force=true",
"code": "BucketNotEmpty" }
| Code | Status | Meaning |
|---|---|---|
Unauthorized |
401 |
Token missing, malformed or expired, or its account was deleted, deactivated or changed since the token was issued |
InvalidCredentials |
401 |
Login rejected: unknown account or wrong password |
Forbidden |
403 |
Authenticated, but the role is not SuperUser |
UserNotFound |
404 |
No user with this id or name |
UserAlreadyExists |
409 |
A user with this name already exists |
RootFromEnvironment |
409 |
The request would change root, which S4_ROOT_USERNAME and S4_ROOT_PASSWORD configure |
IamAdoptionPending |
409 |
Cluster mode: some node still keeps IAM users an earlier release stored on it alone, and IAM changes wait until you decide on them (see Cluster Mode) |
IamAdoptionNotPending |
409 |
No node keeps IAM users of its own: there is nothing to take over |
IamAdoptionReportChanged |
409 |
The nodes no longer answer the report named by report_id: read it again |
InvalidIamAdoption |
400 |
The decision lists a user no node holds, or lists users with a mode other than users |
IamAdoptionIncomplete |
503 |
A node of the pool did not answer: a decision would not cover it. Retry when it is back |
InvalidUsername |
400 |
Username breaks the format rules |
NoS3Credentials |
404 |
The user has no S3 credentials issued |
NoSuchBucket |
404 |
No bucket with this name |
BucketAlreadyExists |
409 |
A bucket with this name already exists |
BucketNotEmpty |
409 |
The bucket still holds objects and force was not set |
InvalidBucketName |
400 |
Bucket name breaks the S3 naming rules |
InvalidData |
400 |
Request body or parameters could not be interpreted |
InvalidArgument |
400 |
max-keys is not between 1 and 1000, or continuation-token is not one a listing answered |
InternalError |
500 |
Server-side fault (storage or IAM subsystem) |
ServiceUnavailable |
503 |
Cluster mode: too few nodes answered to confirm the change or the read; retry when they are back. Also when another IAM change held the cluster's turn for the whole wait; the answer then carries Retry-After in seconds |
500 means a fault on the server, never a mistake in the request — anything the
caller can correct itself is reported as 4xx.