Identity Methods
Since System.Identity 2.29.0 the model declares the security-critical operations of users, groups, clients and data
permissions as CK methods. A method has a fixed contract - parameters, result,
declared error codes and the roles that may call it - so that a caller finds it in the tenant schema and the Identity Service
implements it against exactly that contract.
Version 2.29.0 declares the methods. The Identity Service serves them with handlers that arrive story by story:
User.ChangePassword is the first one (see User.ChangePassword), User.ResetPassword the second (see
User.ResetPassword), followed by the role methods SetRoles, AddRole and RemoveRole of the user (see
User roles); then the group and data permission methods (see Groups and Data permissions) and the client secret methods (see Client secrets); then User.CreateUser and User.Merge, so every declared method now has a handler. A method that is declared but has no handler
answers METHOD_HANDLER_NOT_AVAILABLE. The REST API of the Identity Service and octo-cli keep working unchanged. The Identity
Service is released with the new model only after the handlers are in.
User
| Method | Kind | Who may call it | Parameters | Declared errors |
|---|---|---|---|---|
ChangePassword-1 | instance | the user themselves (allowSelf) | currentPassword, newPassword (both sensitive) | PASSWORD_POLICY_VIOLATION, CURRENT_PASSWORD_INVALID, USER_LOCKED_OUT, SHADOW_USER_PASSWORD_NOT_ALLOWED |
ResetPassword-1 | instance | UserManagement | newPassword (sensitive) | PASSWORD_POLICY_VIOLATION, SHADOW_USER_PASSWORD_NOT_ALLOWED |
SetRoles-1 | instance | UserManagement | roleNames (list) | ROLE_NOT_FOUND |
AddRole-1 | instance | UserManagement | roleName | ROLE_NOT_FOUND, ROLE_ALREADY_ASSIGNED |
RemoveRole-1 | instance | UserManagement | roleName | ROLE_NOT_FOUND, ROLE_NOT_ASSIGNED |
Merge-1 | instance | UserManagement | sourceUserName | SOURCE_USER_NOT_FOUND, CANNOT_MERGE_INTO_ITSELF, MERGE_FAILED |
DisableTwoFactor-1 | instance | UserManagement | none | none (since System.Identity 2.30.0, see below) |
CreateUser-1 | static | UserManagement | userName, email, firstName, lastName (optional), password (optional, sensitive) | USER_NAME_RESERVED, USER_ALREADY_EXISTS, PASSWORD_POLICY_VIOLATION |
ChangePassword is for the signed-in user and needs the current password; an administrator uses ResetPassword. The static
create method is named CreateUser because the names Create, Update and Delete are reserved for the generic mutations.
CreateUser is the only way besides the REST API to create a user, see below. The model does not mark it with
replacesGenericCreate yet (that is a major version change of the model); the platform enforces the same rule.
Group
| Method | Who may call it | Parameters | Declared errors |
|---|---|---|---|
AddMember-1 | UserManagement | memberKind (User or Client), memberId (rtId of the user, client id of the client) | MEMBER_NOT_FOUND, MEMBER_ALREADY_IN_GROUP |
RemoveMember-1 | UserManagement | memberKind, memberId | MEMBER_NOT_FOUND, MEMBER_NOT_IN_GROUP |
SetRoles-1 | UserManagement | roleNames (list) | ROLE_NOT_FOUND |
Client
| Method | Who may call it | Parameters | Result | Declared errors |
|---|---|---|---|---|
AddSecret-1 | TenantManagement, UserManagement | description, expirationDate (both optional) | the generated secret (sensitive, shown once) | CLIENT_SECRET_NOT_REQUIRED |
RemoveSecret-1 | TenantManagement, UserManagement | secretValue (sensitive) | - | SECRET_NOT_FOUND |
Data permission
| Method | Who may call it | Parameters | Declared errors |
|---|---|---|---|
GrantToRole-1 | UserManagement | roleName | ROLE_NOT_FOUND, PERMISSION_ALREADY_GRANTED |
RevokeFromRole-1 | UserManagement | roleName | ROLE_NOT_FOUND, PERMISSION_NOT_GRANTED |
Rules that apply to all of them
- Roles: the roles are the ones of the REST API (
UserManagement; for client secrets alsoTenantManagement). Calling a method also needs theocto_apiscope; without a matching role the call is refused before the target record is read. - Sensitive values: every parameter that carries a password or a secret is declared
sensitive, and so is the result ofAddSecret. Such values never appear in logs, audit entries or traces. A sensitive parameter cannot be handled by a pipeline. - Data access: every method declares
dataAccess: Write: the caller needs a write grant on the target record (method authorization). - Generic create stays available for groups, clients and data permissions: the
System.Identity.Bootstrapblueprint seeds them, and a replaced generic create would make the bootstrap of every new tenant fail. - Versioning: adding the methods is an additive change, a minor version (
2.28.0to2.29.0).
How the Identity Service serves methods
The Identity Service hosts its methods as a remote handler on the message bus: the gateway of the asset repository forwards a call
with the access token of the caller, the Identity Service validates that token again (issuer, signature, expiry, tenant and the
octo_api scope, against its own authority) and only then runs the handler. A call without a valid token is answered
METHOD_FORBIDDEN. Every handler runs in the tenant of the call and writes one entry per call (success or failure) into the
event log of the tenant; the entry carries identifiers and the outcome code, never a parameter value.
Switch for methods that carry a password or secret
| Setting | Default | Meaning |
|---|---|---|
Methods:Identity:EnablePasswordMethods (environment variable OCTO_Methods__Identity__EnablePasswordMethods) | false | Registers the handlers of methods with a sensitive parameter or result (User.ChangePassword, User.ResetPassword, Client.AddSecret, Client.RemoveSecret, User.CreateUser). |
With the default the Identity Service does not announce these methods and the gateway answers
METHOD_HANDLER_NOT_AVAILABLE; the REST API is unaffected. The switch is read at start, so restart the service after changing it.
The method queues carry the parameters. No method with a password or secret may be enabled on staging-1, prod-1 and prod-2 before
the restricted broker user (B0) is rolled out. Local environments and test-2 may enable it with test accounts.
For the Helm deployment set the value as an environment variable of the identity service (OCTO_Methods__Identity__EnablePasswordMethods=true);
leave it unset elsewhere.
User.ChangePassword
A signed-in user changes their own password. The call needs the current password (currentPassword) and the new one
(newPassword).
| Situation | Answer |
|---|---|
| success | the password is changed, the security stamp rotates and all refresh tokens and authorizations of the user in this tenant are revoked (other sessions end) |
| the target is another user, also for an administrator or a service token | METHOD_FORBIDDEN (reason = not-self); the answer is the same whether the target exists or not. An administrator uses ResetPassword. |
| wrong current password | CURRENT_PASSWORD_INVALID; counts like a failed sign-in towards the lockout |
| the user is locked out | USER_LOCKED_OUT (also with a correct current password) |
| the new password violates the policy | PASSWORD_POLICY_VIOLATION with one detail per failed rule (code only, for example PasswordTooShort, PasswordRequiresDigit); never the password |
| cross-tenant shadow user | SHADOW_USER_PASSWORD_NOT_ALLOWED (the password is changed in the home tenant) |
The policy is the one of ASP.NET Core Identity (minimum length 6, digit, lower case, upper case, non-alphanumeric); a client
branches on the detail code, not on a text.
User roles
SetRoles, AddRole and RemoveRole change the direct roles of a user. They follow the rules of the REST endpoints
(PUT /users/{user}/roles, PUT and DELETE /users/{user}/roles/{role}) and never allow more than REST: whoever may not change
a role over REST may not change it with a method either. Roles that a user inherits from a group are neither read nor written.
These methods carry no password or secret, so they are not behind the switch above and are available whenever the Identity
Service runs.
| Method | What it does | Answers |
|---|---|---|
SetRoles (roleNames) | replaces the direct roles by the given list; an empty list removes all direct roles | ROLE_NOT_FOUND if a name is unknown: then nothing is changed |
AddRole (roleName) | adds one role | ROLE_NOT_FOUND, ROLE_ALREADY_ASSIGNED |
RemoveRole (roleName) | removes one role | ROLE_NOT_FOUND, ROLE_NOT_ASSIGNED |
The rules, side by side with REST:
| Rule | REST | Method |
|---|---|---|
the caller needs the tenant role UserManagement and the scope octo_api (the read-only scope is not enough) | 403 | METHOD_FORBIDDEN; the Identity Service checks this again on the validated access token, so a forged message on the bus is refused as well |
| the user must exist | 404 | METHOD_TARGET_NOT_FOUND |
| the role must exist (names are not case sensitive) | 404 | ROLE_NOT_FOUND, the parameter roleName names the role |
| a role that is already assigned cannot be added | 400 | ROLE_ALREADY_ASSIGNED |
| a role that is not assigned cannot be removed | 400 | ROLE_NOT_ASSIGNED |
| service clients are judged by their client roles | allowed with UserManagement | allowed with UserManagement |
REST and the methods also share the rules against escalation, which are described with the roles in
Users and Roles. A refused call answers METHOD_FORBIDDEN with the
parameter reason (no further error code is declared, the model is unchanged):
reason | Meaning | REST |
|---|---|---|
role-required | the caller lacks the role UserManagement or the scope octo_api | 403 |
self-grant | the caller tried to add a direct role to their own user | 403, code = self-grant |
role-not-held | the caller tried to grant a built-in role they do not hold; the parameter roleNames names the roles | 403, code = role-not-held, roleNames |
last-administrator | the change would leave the tenant without a user who holds UserManagement | 409, code = last-administrator |
SetRoles is judged on the difference to the current direct roles. One unknown role name changes nothing in either transport
(ROLE_NOT_FOUND; REST answers 404).
Like in REST, a role change does not end running sessions: the user's next token carries the new roles. Every call, including
every refusal, writes one entry into the event log of the tenant (category Identity.Method) with the calling user, the target,
the outcome (for a refusal also the reason and the refused roleNames) and the direct roles before and after the call.
User.DisableTwoFactor
A user administrator (role UserManagement) switches off the second factor of a user, for example when the user lost the
authenticator app or the authenticator key did not survive a restore. Declared since System.Identity 2.30.0. The method carries no
password or secret, so it is served regardless of Methods:Identity:EnablePasswordMethods. The REST endpoint
POST {tenantId}/v1/users/{userName}/disableTwoFactor does the same and answers { "twoFactorWasEnabled": true|false }.
| Situation | Answer |
|---|---|
| success | the authenticator key and the recovery codes are removed, TwoFactorEnabled is false, the security stamp rotates and all refresh tokens and authorizations of the user in this tenant are revoked. The user signs in with the password and can set up a new authenticator in the account settings. A user without a second factor gets the same treatment. |
the caller does not have the role UserManagement (or the scope octo_api) | METHOD_FORBIDDEN; the answer is the same whether the target exists or not |
| the target does not exist (authorized caller only) | METHOD_TARGET_NOT_FOUND |
| a write or the revocation fails | METHOD_HANDLER_FAILED; never a success while the key or old refresh tokens survive |
Audit. Every call writes one entry into the event log of the tenant (category Identity.Method) with the caller, the target, the
outcome and twoFactorWasEnabled, and the Identity Service writes one log line naming tenant, user, caller and path. No key, recovery
code or token is ever logged. Setting TwoFactorEnabled through a generic GraphQL mutation or an import is refused, see
Fields Only the Identity Service Writes.
User.CreateUser
A user administrator (role UserManagement) creates a user, with an initial password or as an invitation without one. Static
method: it is called on the type, not on a user. The method has a sensitive parameter (password), so it is served only when
Methods:Identity:EnablePasswordMethods is true, also for an invitation
without a password; with the default false the gateway answers METHOD_HANDLER_NOT_AVAILABLE. The REST endpoint
POST {tenantId}/v1/users does the same; both are the only ways to create a local user (see
Creating Users).
| Situation | Answer |
|---|---|
| success | the user is created through the user manager of the Identity Service: password policy (checked before the user is stored), normalized user name and e-mail, new security stamp. With password the user can sign in at once and gets the e-mail confirmation; without it (or with a blank one) the user gets the welcome mail with a link to set a password. The result is the id (rtId) of the new user, as a plain string in result ("result": "6ae7c66ccc5a608ccf779df9"). |
the user name starts with xt_ (reserved for cross-tenant users) | USER_NAME_RESERVED |
| a user with this name (any casing) or this e-mail address exists | USER_ALREADY_EXISTS with the parameter field = userName or email. The REST endpoint POST {tenantId}/v1/users applies the same rule (409, code: USER_ALREADY_EXISTS, field: userName or email; before release 3.5.0 a duplicate user name answered 400), also when an existing user is renamed or changes to the address (PUT {tenantId}/v1/users/{userName}). |
| the password violates the password policy | PASSWORD_POLICY_VIOLATION; the details name the failed rules (for example PasswordTooShort), never the value. Nothing is stored. |
| the user name is missing or contains characters the Identity Service does not accept, or the e-mail address is invalid | METHOD_VALIDATION_FAILED with the parameter field |
the caller does not have the role UserManagement (or the scope octo_api) | METHOD_FORBIDDEN (the gateway refuses it before the call; the Identity Service checks again) |
Audit. Every call writes one entry into the event log of the tenant (category Identity.Method) with the caller, the outcome,
the user name, whether a password was given (withPassword) and, on success, the id of the new user. The password is never logged,
audited or returned in an error.
Generic create. A GraphQL create (generic, typed or a runtime-query row), an MCP create, a pipeline insert, a user-started
ImportRt and the seed data of a blueprint refuse a new user with GENERIC_CREATE_REPLACED_BY_METHOD and the method id
System.Identity/User.CreateUser-1; an existing user can still be updated (within
Fields Only the Identity Service Writes).
User.Merge
A user administrator (role UserManagement) merges a duplicate user into another one, for example a local account and the
account an external sign-in created for the same person. The method is called on the user that is kept (the target) and names the
duplicate in sourceUserName. It carries no password or secret, so it is served regardless of
Methods:Identity:EnablePasswordMethods. The REST endpoint POST {tenantId}/v1/users/{userName}/merge (body
{ "sourceUserName" }) runs the same code and leaves the same state behind (see
Merging Duplicate Users).
| Situation | Answer |
|---|---|
| success | the external logins of the source user (for example Google or Microsoft) move to the target user, all refresh tokens, authorizations and browser sessions of the source user in this tenant are revoked, and the source user is deleted. The next sign-in with a moved login finds the target user. Roles, group memberships, claims and cross-tenant mappings of the source user are not transferred; they go away with the source user, so a merge never gives the target user a role. |
| the source user does not exist, or the caller cannot read it (data permissions) | SOURCE_USER_NOT_FOUND |
| source and target are the same user | CANNOT_MERGE_INTO_ITSELF |
sourceUserName is missing | METHOD_VALIDATION_FAILED |
| the caller may read the source user but not change it (data permissions) | METHOD_FORBIDDEN with the parameter reason = data-permission |
the source user is the last user who holds UserManagement | METHOD_FORBIDDEN with reason = last-administrator (REST: 409), see Rules for Changing the Roles of a User |
the caller does not have the role UserManagement (or the scope octo_api) | METHOD_FORBIDDEN; the answer is the same whether the target exists or not |
| the target does not exist (authorized caller only) | METHOD_TARGET_NOT_FOUND |
| a login could not be moved or the source user could not be deleted | MERGE_FAILED with the parameter step (removeLogin, addLogin or deleteSource), loginProvider for the login steps, and the error codes of the user manager as details |
A refused call changes nothing.
Audit. Every call writes one entry into the event log of the tenant (category Identity.Method) with the caller, the target,
the outcome, the source user (sourceUserName, sourceRtId) and, after a merge, the number of moved logins
(transferredLogins) and revoked tokens (revokedTokens); a refusal adds reason. The Identity Service also writes one log line
for the revocation naming tenant, source user and caller. No token is ever logged or audited.
User.ResetPassword
A user administrator (role UserManagement) sets a new password without the current one, also for a user who is locked out
after too many failed sign-ins. The call needs only the new password (newPassword). Like every method that carries a password
it is served only when Methods:Identity:EnablePasswordMethods is true;
with the default (false) the gateway answers METHOD_HANDLER_NOT_AVAILABLE.
| Situation | Answer |
|---|---|
| success | the password is changed, the security stamp rotates, all refresh tokens and authorizations of the user in this tenant are revoked (every session ends) and an active lockout is lifted (LockoutEnd is cleared, the failed-sign-in counter is 0). The user can sign in with the new password at once. |
the caller does not have the role UserManagement (also a user resetting their own password, or a forged call on the bus) | METHOD_FORBIDDEN; the answer is the same whether the target exists or not |
| the target does not exist (authorized caller only) | METHOD_TARGET_NOT_FOUND |
| the new password violates the policy | PASSWORD_POLICY_VIOLATION with one detail per failed rule (code only, for example PasswordTooShort); never the password. Nothing is changed, a lockout stays. |
| cross-tenant shadow user | SHADOW_USER_PASSWORD_NOT_ALLOWED (the password is changed in the home tenant) |
Audit. Every call writes one entry into the event log of the tenant (category Identity.Method), for success and for every
failure: the tenant, the caller (callerSubject: user id or client id), the target (targetRtId), the outcome (SUCCESS or the
error code) and lockoutLifted (true when the call lifted an active lockout, otherwise false). The password is never part of a
log line, an audit entry or a trace.
Difference to the REST endpoint. POST system/v1/users/ResetPassword does not lift a lockout: the user stays locked out
until the lockout time has passed, and the endpoint e-mails the user a second reset token. The method lifts the lockout and sends
no e-mail; a notification to the user is not part of the method yet. The REST endpoint is unchanged.
Groups
The Identity Service serves Group.AddMember, Group.RemoveMember and Group.SetRoles with the rules of the REST endpoints
system/v1/groups/{group}/members/... and .../roles, never with more. They are hosted whatever
EnablePasswordMethods says: they carry no password or secret.
| Method | What it does | Answers |
|---|---|---|
AddMember (memberKind, memberId) | adds a user or a client to the group | MEMBER_NOT_FOUND, MEMBER_ALREADY_IN_GROUP |
RemoveMember (memberKind, memberId) | removes a user or a client from the group | MEMBER_NOT_FOUND, MEMBER_NOT_IN_GROUP |
SetRoles (roleNames) | replaces the roles of the group by the given list; an empty list removes all roles | ROLE_NOT_FOUND if a name is unknown: then nothing is changed |
memberId is the id of the user or of the client (the route parameter of REST); for a client the OAuth client id is accepted as
well. SetRoles takes role names (not case sensitive); REST takes role ids.
The rules, side by side with REST:
| Rule | REST | Method |
|---|---|---|
the caller needs the tenant role UserManagement and the scope octo_api | 403 | METHOD_FORBIDDEN (reason = role-required); checked again on the validated access token, so a forged message on the bus is refused |
| the group must exist | 404 | METHOD_TARGET_NOT_FOUND |
| the member or role must exist, and the caller must be allowed to read it | an unhandled engine error | MEMBER_NOT_FOUND, ROLE_NOT_FOUND |
| a member cannot be added twice, a non-member cannot be removed | silent no-op | MEMBER_ALREADY_IN_GROUP, MEMBER_NOT_IN_GROUP |
| a caller cannot give themselves a role (also not through a group they belong to) | 403, code = self-grant | METHOD_FORBIDDEN, reason = self-grant |
| a caller cannot give a built-in role they do not hold themselves | 403, code = role-not-held | METHOD_FORBIDDEN, reason = role-not-held, roleNames |
the change cannot leave the tenant without a user who holds UserManagement (judged over direct roles, groups, nested groups and mappings) | 409, code = last-administrator | METHOD_FORBIDDEN, reason = last-administrator |
| removing a client is not judged (a client gives nothing to anybody) | allowed | allowed |
The rules against escalation are the same code as for the roles of a user, see Users and Roles. Like in REST,
a change does not end running sessions: the member's next token carries the roles of the group as they are then. Every call,
including every refusal, writes one entry into the event log of the tenant (category Identity.Method) with the calling user, the
group, the outcome, memberKind and memberId (for SetRoles the role names before and after, for a refusal also the reason).
Data permissions
DataPermission.GrantToRole and DataPermission.RevokeFromRole grant or take away a data permission for a role by role name (not
case sensitive), with the rules of POST and DELETE system/v1/dataPermissions/{permissionId}/roles/{roleName}. The data
permission is the target of the call.
| Situation | Answer |
|---|---|
| success | the role holds (or no longer holds) the permission |
the caller lacks the role UserManagement or the scope octo_api | METHOD_FORBIDDEN (reason = role-required) |
| the data permission does not exist | METHOD_TARGET_NOT_FOUND (REST: an unhandled error) |
| the role does not exist, or the caller may not read it | ROLE_NOT_FOUND |
| the caller may read the role but not change it | METHOD_FORBIDDEN (reason = data-permission) |
GrantToRole for a role that already holds the permission | PERMISSION_ALREADY_GRANTED (REST: no-op) |
RevokeFromRole for a role that does not hold it | PERMISSION_NOT_GRANTED (REST: no-op) |
A data permission grants access to data, not roles: like REST, the methods apply no escalation rules to it. The role is checked
with the data permissions of the caller; as long as no policy protects the role type, the check is open, as in REST. Every call
writes one entry into the event log of the tenant (category Identity.Method) with permissionId and roleName.
Client secrets
Client.AddSecret generates a new secret for a client and returns it once as the result of the call; Client.RemoveSecret
removes a secret again. The client is the target of the call (its record id). Both carry a secret, so they are served only when
Methods:Identity:EnablePasswordMethods is true; with the default
(false) the gateway answers METHOD_HANDLER_NOT_AVAILABLE. They follow the REST endpoints POST and
POST system/v1/apiSecrets/client/{clientId}/delete (stored hash in the request body), which stay unchanged.
| Method | What it does | Answers |
|---|---|---|
AddSecret (description, expirationDate, both optional) | generates a secret of 256 random bits (URL-safe Base64), stores only its SHA-256 hash and returns the secret | CLIENT_SECRET_NOT_REQUIRED for a public client (it never authenticates with a secret); METHOD_VALIDATION_FAILED for an expiration in the past (field = expirationDate) or the reserved description octo:mirror-own-secret (field = description) |
RemoveSecret (secretValue) | removes the secret; secretValue is either the stored value, as the list of secrets shows it, or the secret itself | SECRET_NOT_FOUND when the client has no such secret (the answer never repeats the value) |
- Shown once. The result of
AddSecretis the only place where the secret appears. It is declaredsensitive: the gateway hands it to the caller but never writes it into a log, an audit entry or a trace. Store it right away (for example in a vault); if it is lost, add a new secret and remove the old one. - Effect. A client that is configured for the client credentials grant can request tokens with the new secret at once
(
AddSecretdoes not change the allowed grant types). AfterRemoveSecret, a token request with the removed secret fails at once. Other secrets of the client keep working, so a secret can be rotated without a gap: add the new one, switch the caller, remove the old one. - Mirrors. For a client that is mirrored into child tenants the change is copied to the mirrors after the write. If that copy fails, the call still succeeds (the change is stored); every Identity Service instance repairs the mirrors on its next periodic check, by default within 5 minutes, so a removed secret is then refused in the child tenants too (Mirrored clients).
- Callers. The caller needs the scope
octo_apiand the tenant roleTenantManagementorUserManagement, as for REST. The Identity Service checks this again on the validated access token; without it the call is refused withMETHOD_FORBIDDEN(reason=role-required), the same answer whether the client exists or not. An unknown client isMETHOD_TARGET_NOT_FOUND. - Audit. Every call that reaches the Identity Service, including every refusal there, writes one entry into the event log of the
tenant (category
Identity.Method) with the caller, the target (targetRtId) and the outcome; once the client was found alsoclientId, after a writesecretCount, and forRemoveSecretremovedCount. A call the gateway refuses before it is dispatched is recorded by the gateway (Method.Invocation), not here. The secret, its hash and the description are never part of an entry.