Skip to main content

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.

danger
null does not clear the roles

With 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.

CallNon-empty listEmpty / null list
assignRolesReplaces the whole setSends the principal's own current list (no-op)
removeRolesRemoves the named idsMarks touched, removes nothing
assignGroupsAppendsShort-circuits — no request is sent
removeGroupsRemoves the named idsClears 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
It only fires when the package carries exactly one principal

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 passThe key server is asked for
UserAndPasswordInfoSRPPrincipalInfo
UserAndPasswordInfo_V1UserAndPasswordInfo

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_array is null — a plain principal lookup. This is what you want.
  • sid_array is 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:

  1. 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.
  2. 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.
  3. 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 a user value that is not the caller's own name is refused with UPDATE_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.

"A refused batch creates nothing" is still not quite true

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_SHORT if 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.