Administering principals and roles
The administrative surface is reached through the Administration class. Most of it does
what its signature suggests. This page is about the parts that do not — each one verified
against the handler that implements it, on the key server side wherever that is where the
behavior is decided.
Attaching a client
An admin client is constructed empty and then attached to a connection:
import { Administration } from 'server-ts-agent';
const admin = new Administration();
admin.attachConnection(conn);
Every member dereferences the attached connection with no guard, so calling one before
attachConnection() is a synchronous TypeError, not a rejected promise. Attaching a
second connection detaches the first; detachConnection() returns the previous one.
Remember that the connection carries one operation at a time — see
the connection model. Build the client
inside withConnection, not once and shared.
Principals
assignRoles replaces; it does not add
assignRoles(pInfo, roleIDs): Promise<void>
With a non-empty list it clears the principal's role list and then adds the ids you
gave — a replace, not an append. Callers expecting assignRoles(p, [newRole]) to grant
one more role will silently remove every other role the principal held.
null does not clear the rolesWith null, undefined or [], assignRoles marks the role list as touched without
changing it, and sends the principal's own current list back. On a principal you have
just fetched, that is a no-op, not a clear.
To actually clear a principal's roles, clear the object first and then send it:
p.clearRoles();
await admin.assignRoles(p, []); // now this really does send an empty list
removeRoles is the counterpart and behaves the same way in the empty case: with null,
undefined or [] it marks the list touched without removing anything.
Both mutate pInfo in place and resolve with nothing. The updated object is the one
you passed.
The group side is not the mirror image
assignGroups is purely additive — there is no clear anywhere on its path — and given
an empty list it short-circuits: it returns the object you passed without reaching
the key server at all. So clearGroup() followed by assignGroups(p, []) clears your
local object and sends nothing.
To clear a principal's groups, use removeGroups(p, null): its empty branch calls
clearGroup() and does send.
| Call | Non-empty list | Empty / null list |
|---|---|---|
assignRoles | Replaces the whole set | Sends the principal's own current list (no-op) |
removeRoles | Removes the named ids | Marks touched, removes nothing |
assignGroups | Appends | Short-circuits — no request is sent |
removeGroups | Removes the named ids | Clears the group list and sends |
The self-principal guard is not where you would look for it
It is common to read that "the key server refuses assignRoles on your own principal".
That is too narrow in a way that matters.
The guard is on the UPDATE_PRINCIPAL handler, not on any one agent method. Every
call that sends UPDATE_PRINCIPAL is therefore covered by it — assignRoles,
removeRoles, assignGroups, removeGroups and updatePrincipal alike. The refusal
carries this text:
Cannot modify own account
The handler loops over the principals in the request. When it reaches one that equals the session's own principal it checks the size of the package:
- exactly one object — it refuses, with the message above;
- more than one — it silently skips that principal and carries on with the rest.
So a batch of two, one of which is you, succeeds. Your own row is not modified, no error is raised, and nothing in the reply says which principals were skipped. The same silent skip applies to system-defined principals in any package.
If you need to know that your own principal was excluded, send one principal per request, or read the affected principals back.
The refusal is raised as an authorization failure on the key server side, but that
classification does not reach you as a numeric code — see
Errors. Cannot modify own account is the whole
signal.
updatePrincipal answers in your argument object
updatePrincipal(pal): Promise<void>
It resolves void. The updated principal — the key server's own object, with whatever the
server filled in — is assigned into the object you passed, and that object's dirty
field set is then cleared. If you are holding a reference to the principal you sent, you
are already holding the result; if you discarded it, the result is gone.
removeGroups and assignGroups differ here: they resolve the updated principal as
well as clearing the sent object's dirty set.
getPrincipalListWithClassNames rewrites two of your class names
getPrincipalListWithClassNames(classNames): Promise<Principal[]>
Two of the names you pass are silently substituted before they go on the wire:
| You pass | The key server is asked for |
|---|---|
UserAndPasswordInfo | SRPPrincipalInfo |
UserAndPasswordInfo_V1 | UserAndPasswordInfo |
Every other name is forwarded verbatim. GroupInfo is a valid class name here, because
a group is a principal.
Passing no names at all sends the request with no class filter.
getPrincipalListWithIds switches operation on its first argument
getPrincipalListWithIds(sid_array, pid_array): Promise<Principal[]>
This is a read whose behavior changes on an argument that looks like a filter:
sid_arrayisnull— a plain principal lookup. This is what you want.sid_arrayis non-null — a different operation, the read that also writes. On the key server it builds a disable-entries package and disables local ACL entries for the principals it looked up.
Pass null deliberately. Supplying the system id "because we have it" turns a read into
an authorization mutation. When sid_array is supplied its length must match
pid_array's, or the call rejects with ERR_SID_PID_SIZE_MISMATCH; an empty
pid_array resolves [] without a round trip.
getUserByName cannot see groups
getUserByName builds a user object, sends a find-principal request and then requires the
result to be a UserAndPasswordInfo. A group is a principal but it is not a
UserAndPasswordInfo, so a group name never resolves through this call, however
correctly it is spelled.
Do not match on the rejection text either. The call is written to raise
ERR_NO_PRINCIPAL for an unknown name, but measured against 8.1 the key server refuses
the find itself with Find Principal Failed, so the rejection arrives from the transport
before the empty-result branch is reached. ERR_NO_PRINCIPAL is reachable only through
the wrong-class branch. Treat any rejection as "no such user."
resetPassword — read this before using it either way
resetPassword(name, password): Promise<AdminReply>
name is not a target selector. The object this call sends carries
connection.getLoginID() — the caller's own principal id — whatever name says.
name fills the username column and is the input the SRP verifier is derived over.
This has a documented history that is worth knowing, because the intermediate version of the story was wrong in a dangerous direction:
- It was long recorded that the operation "can only ever change the caller's own password", read off a measurement in which an administrator naming another existing principal was refused.
- That reading was withdrawn. The refusal was a database uniqueness collision on the username column — the call was asking the store to give the caller's row a username that was already taken — not an engine policy. Against a free name there was no collision: the write succeeded and it renamed the caller's own account and reset the caller's own password, binding the new verifier to the new name.
- The engine now refuses both cases outright. A principal that does not match the
session's is refused with
UPDATE_PRINCIPAL_PASSWORD may only change the calling principal's own password, and auservalue that is not the caller's own name is refused withUPDATE_PRINCIPAL_PASSWORD cannot change a principal's name.
So the headline sentence is true on a current key server — and it is enforced rather than incidental. Pass the caller's own name, and treat this as a self-service password change only.
An administrator setting another principal's password is a different operation entirely: a principal update carrying verifier, salt and index as dirty fields. The password reset call is not a route to it.
Roles
createRoles — the database half is atomic and the cache half is not
The batch is wrapped in one transaction with rollback on every failure path, so a refused batch writes no role rows at all, in either order of the batch. An empty role name is refused for the whole batch in its own pass before the transaction opens, so an invalid batch costs no database work.
Only the database half is transactional. The process-wide role cache is populated in a
second loop that runs after the commit, outside the transaction, and no rollback
reaches in-memory state. A failure part-way through that loop leaves the database
committed and the authorization cache partially populated, and the caller is told
Create Role Failed.
The practical answer is the one the Tricryption REST Gateway takes: create exactly one role per request. That makes the window one entry wide and gives "did it save" a single answer.
Other measured properties of the call:
- Duplicate names are refused, deterministically. The key server keys on the role name.
- Empty names are refused, deterministically, with
CREATE_ROLE: a role name may not be empty. - Role ids arrive in the reply and cannot be known in advance. The call resolves the
created roles — the key server's own objects, in the order sent — so
getRoleID()on each is the server-assigned 8-byte id. An empty input resolves[]without a round trip. - A rejection carries no ids. If you need to know what landed after a refusal, list the roles and match on the name.
- It rejects with
ERR_CREATE_ROLE_REPLY_SHORTif the key server returns fewer objects than roles were sent — a protocol violation rather than a caller error, raised instead of handing back a list with holes in it.
updateRoles is the twin and runs under the same single transaction.
Building a role
createRoles takes roles the caller built, so the Role constructor is exported for
exactly that reason. Operation deliberately gets no value export: no member takes one,
so nothing needs to build one.
Where the ids are Buffers
Several parameters and returns that read like numbers are 8-byte Buffers: principal
ids, group ids and role ids all are. getLoginID() returns one, and it is
Buffer.alloc(0) on a connection that has not authenticated. This is stated in the
package's type declarations, which your editor will show you at the call site — but it is
the single most common first mistake, so it is worth stating here too.