Provisioning over HTTP deprecated

Provisioning over HTTP

The provisioning API is available over HTTP and JSON, as an alternative to SOAP. A gateway translates the JSON requests into the gRPC calls the middleware serves internally, so the same operations are reachable without a SOAP client.

SOAP is not deprecated. Both interfaces stay available and act on the same data; a system can use either, or both at once.

Where an identity provider is to drive the users, groups, resources and shared accounts of a context, SCIM provisioning covers that lifecycle with the connector the provider already ships. It builds on the same provisioning services and authenticates with the tokens issued here.

Enabling it

The gateway runs as a sidecar next to the middleware pods that serve provisioning. It is off by default:

provisioningGateway:
  enabled: true

That is enough for a first look. Everything below has a working default:

Value Default What it is
role admin The pod role carrying the gateway. It reaches the middleware over the pod's loopback interface, so it has to sit next to one that serves provisioning.
port 8080 Port the gateway listens on.
grpcPort 8066 The middleware's gRPC port, i.e. com.openexchange.grpc.server.port. Change it here as well if you change it there.
timeout 600s How long a call may take before the gateway gives up on it, see When the gateway gives up. 0s disables it.
service.type / service.port ClusterIP / 80 The Service in front of the gateway.

Reaching it

The chart creates a Service named <release>-provisioning-gateway in the release namespace, of type ClusterIP by default. It creates no Ingress — this chart defines none for any of its services, including the middleware itself. Routing traffic to the gateway from outside the cluster is therefore the same step, and uses the same mechanism, as exposing the middleware: an Ingress, an Istio VirtualService or whatever the installation already uses, pointed at that Service.

Do not expose it the way the end-user HTTP API is exposed. This endpoint is administrative and belongs behind whatever restriction the SOAP provisioning endpoint sits behind today.

The gateway, like the gRPC services behind it, answers for the contexts of its own site. In a deployment with several sites, call the gateway of the site a context lives on. RMI clients need not care: RMI passes a call on to the site of its context.

Securing it

The endpoint carries administrative credentials. Treat it like the SOAP endpoint: do not publish it, and terminate TLS in front of it.

provisioningGateway:
  tls:
    enabled: true
    existingSecret: provisioning-gateway-tls
  service:
    port: 443

The Secret is an ordinary kubernetes.io/tls Secret with tls.crt and tls.key. The chart does not generate a certificate — rendering fails if existingSecret is empty while TLS is enabled. Without TLS the credentials travel in clear text, which is only defensible on a trusted, non-routable network.

Which operations are exposed

Only the services listed under services are reachable. That list is the endpoint's attack surface, so it is worth a deliberate decision rather than a copy of everything available.

The default is the set SOAP already offers, so a caller can move off SOAP without losing an operation it had: contexts, users, groups, resources, shared and secondary accounts, reseller, deputy permissions, the server/database/filestore registry (SOAP's OXUtilService), job management (OXTaskMgmtService), user copy, session management (OXSessionService) and provisioning tokens (ProvisioningTokenService, SOAP's OXProvisioningTokenService, see below).

SessiondService carries one operation SOAP does not, ClearSessionStorage, which drops the whole session storage rather than the sessions of one user or context. Envoy filters by service, not by method, so exposing the service exposes that one too; leave it out of services where that is not wanted.

ChronosService is on the list for the same reason. Its single operation reassigns the organizer of an event after the previous organizer was deleted, and it is part of the documented provisioning API - without it an HTTP-only caller would have no way to reach that operation at all.

Everything beyond that stays opt-in — in particular the cluster maintenance services (ExtendedUpdateTaskService, DBMigrationService, SchemaService, ContextRestoreService, ConsistencyService). These are not what a provisioning client needs, and they include operations such as starting an update run.

MailCredentialAdminService is opt-in too. It lists, repairs, refreshes and releases the mail credentials the mail credential vault keeps for a user - for snoozed mail, scheduled mail, the GDPR mail export, permanent push and personal access tokens - for the master administrator or the administrator of the user's context. Nothing it returns reveals what a credential holds or the login it was kept for:

# what is kept for the user, and where each credential stands
curl -u oxadmin:secret https://<host>/prov/v1/contexts/1/users/3/mail-credentials

# release one, e.g. after its refresh token was rejected for good
curl -u oxadmin:secret -X DELETE https://<host>/prov/v1/contexts/1/users/3/mail-credentials/<id>

# release all of a feature
curl -u oxadmin:secret -X DELETE 'https://<host>/prov/v1/contexts/1/users/3/mail-credentials?feature=push'

# repair a rejected one now from a live session of the user, or refresh one's tokens now
curl -u oxadmin:secret -X POST https://<host>/prov/v1/contexts/1/users/3/mail-credentials/<id>:repair
curl -u oxadmin:secret -X POST https://<host>/prov/v1/contexts/1/users/3/mail-credentials/<id>:refresh

state is one of kept_alive (refreshed next at refreshDue), due, not_kept_alive (a password, tokens without a refresh token, nothing, or the keep-alive is switched off), rejected (the identity provider rejected the refresh token; a session of the user repairs it) or expired. callerKey marks a credential only its caller's secret opens, as for a personal access token. A release logs out tokens obtained through a token exchange; whatever relied on the credential falls back to a live session of the user. Releasing an identifier the user does not own answers "released": false. A repair or refresh answers an outcome - e.g. repaired, no_live_session, refreshed or deferred; caller_key for a credential only a presentation of its token opens, not_found for one the user does not own - and the credential as it stands afterwards.

To expose one, add its full gRPC service name:

provisioningGateway:
  services:
    - com.openexchange.grpc.provisioning.ContextService
    - com.openexchange.grpc.provisioning.UserService
    # … the rest of the default list …
    - com.openexchange.grpc.provisioning.SchemaService

A service name that does not exist is rejected when the gateway starts, so a typo shows up as a failing container rather than as a silently missing endpoint.

Calling it

Paths start with /prov/v1/, and resources are nested the way they belong together:

GET    /prov/v1/contexts
POST   /prov/v1/contexts
PATCH  /prov/v1/contexts/{context_id}
DELETE /prov/v1/contexts/{context_id}
POST   /prov/v1/contexts/{context_id}:enable
GET    /prov/v1/contexts/{context_id}/users
GET    /prov/v1/contexts/{context_id}/groups/{group_id}
POST   /prov/v1/contexts/{context_id}/users/{user_id}/deputies:grantMultiple
POST   /prov/v1/contexts/{context_id}/tokens
DELETE /prov/v1/contexts/{context_id}/tokens/{token_id}

Operations that are not a plain create/read/update/delete carry a verb suffix after a colon, as in :enable or :grantMultiple.

Two things about these paths are worth knowing before writing a client:

  • Path parameters carry plain identifiers and are named after the request field in snake case: {context_id}, {user_id}, {group_id}. Where an object may be addressed by name instead, the path offers a second parameter such as {resource_name}.
  • There is no endpoint for reading a single context. One is read through the collection, by passing its identifier as a filter to GET /prov/v1/contexts. The same holds for users and resources: the collection endpoint takes identifiers, a pattern and paging as parameters. Groups are the exception and can be read one at a time.

Credentials go into the Authorization header, as HTTP basic — never into the request body. The login is the admin's own; which context it acts on comes from the path, not from the login. This is the point where a SOAP client will trip: SOAP carries them inside the message, and the same habit produces a request the gateway rejects.

curl -u oxadminmaster:secret https://<host>/prov/v1/contexts

curl -u oxadmin:secret \
     -H 'Content-Type: application/json' \
     -d '{"user": {"name": "jdoe", "displayName": "John Doe"}}' \
     https://<host>/prov/v1/contexts/1/users

Contexts are addressed numerically. SOAP accepts a context by name in places where the HTTP path takes the identifier.

An OpenAPI document describing every path, parameter and response is generated from the API definition itself and published alongside the other API documentation:

https://documentation.open-xchange.com/components/middleware/provisioning/<version>/

That page renders the description in a browser. The document itself sits next to it as provisioning.swagger.json and is the file to point a client generator at.

Provisioning tokens

Automated clients that cannot hold an administrator's password, an identity provider driving the SCIM endpoint or a script driving this gateway for instance, authenticate with a provisioning token instead. A token is bound to one scope and opens that one interface and nothing else: scope SCIM opens the SCIM endpoint, scope PROVISIONING opens /prov itself, presented as Authorization: Bearer <secret>. A token acts as an administrator of the contexts it is bound to, never as the master administrator, and it cannot manage tokens.

Tokens are managed over the gateway by the context administrator, a reseller administrator owning the context or, where MASTER_ACCOUNT_OVERRIDE permits it, the master administrator - the same rule every other operation inside a context follows. The same operations exist without a gateway, as the command line tools createprovisioningtoken, listprovisioningtokens, revokeprovisioningtoken and detachprovisioningtoken and as the SOAP service OXProvisioningTokenService (create, list, revoke, listCrossContextReachingInto, detachCrossContext); over the gateway they are:

# create; the secret is part of this response only
curl -u oxadmin:secret \
     -H 'Content-Type: application/json' \
     -d '{"label": "Entra ID", "scope": "SCIM", "expires": 0}' \
     https://<host>/prov/v1/contexts/1/tokens

# list (metadata only)
curl -u oxadmin:secret https://<host>/prov/v1/contexts/1/tokens

# revoke
curl -u oxadmin:secret -X DELETE https://<host>/prov/v1/contexts/1/tokens/<token_id>

# use a token of scope PROVISIONING on the gateway
curl -H 'Authorization: Bearer ox_1_...' https://<host>/prov/v1/contexts/1/users

The secret has the form ox_<context-id>_<64 hex characters>. Only its SHA-256 hash is stored, so a lost secret cannot be recovered; revoke the token and create a new one. expires is a point in time in milliseconds since the epoch, 0 means the token does not expire. Every successful use records its time, visible as lastUsed in the listing, which is how a forgotten token can be spotted.

Cross-context tokens

A client working across contexts - one granting shared account permissions between them, say - needs a credential that stands above every context involved, which no context administrator's password does. A cross-context token opens several contexts at once: it is created for an explicit list of at least two contexts, and only by the master administrator or a reseller administrator owning every one of them. It is stored installation-wide rather than in a context, its secret has the form ox_x_<64 hex characters>, and it is accepted on /prov for exactly the contexts it names - a context it does not name is answered with 401, as is any attempt to act as the master administrator, such as creating a context. When one of its contexts is deleted, the token drops that context; when the last one is deleted, the token is deleted with it.

Creating one requires MASTER_ACCOUNT_OVERRIDE, the switch that decides whether an administrator may reach into a context at all. With the override off nobody issues a token that acts inside a context - otherwise the master administrator could hand itself the access the override denies it.

Listing and revoking are not tied to the switch for the master administrator: whatever was issued before it was turned off can still be seen and taken back. A reseller administrator, like everywhere else, reaches its contexts only while the override permits it, so with the override off its own tokens are out of its reach - the master administrator revokes them. A token stays valid until it is revoked; turning the override off does not stop the tokens already handed out.

What the context reached into can do

A cross-context token is issued above the contexts, so the context it opens has no part in it. It is not left without a say either: its administrator can see which cross-context tokens open the context and end that one context's exposure, the way both sides of a shared account permission can end it.

The listing names the asked-about context and no other - which further contexts a token opens is not disclosed to it. Detaching is not a revocation: the token keeps working for every other context it opens, and is deleted only if this was the last one. It is not a lasting veto either, since an administrator above the contexts may issue a new token covering the context again; to end a token everywhere at once, revoke it.

# what reaches into context 1, as its administrator sees it
curl -u contextAdmin:secret https://<host>/prov/v1/contexts/1/cross-context-tokens

# end this context's exposure; the token keeps opening the others
curl -u contextAdmin:secret -X DELETE \
     https://<host>/prov/v1/contexts/1/cross-context-tokens/<token_id>
# create; needs the master administrator or a reseller owning contexts 1 and 2
curl -u oxadminmaster:secret \
     -H 'Content-Type: application/json' \
     -d '{"contextIds": [1, 2], "label": "Shared account automation", "scope": "PROVISIONING"}' \
     https://<host>/prov/v1/tokens

# list the cross-context tokens the caller stands above (metadata only)
curl -u oxadminmaster:secret https://<host>/prov/v1/tokens

# revoke
curl -u oxadminmaster:secret -X DELETE https://<host>/prov/v1/tokens/<token_id>

# use it: one header, two contexts
curl -H 'Authorization: Bearer ox_x_...' \
     -H 'Content-Type: application/json' \
     -d '{...}' \
     https://<host>/prov/v1/contexts/2/sharedaccounts/permissions

The command line tools take --contexts 1,2 instead of -c, and list or revoke cross-context tokens when no context is given; the SOAP service offers createCrossContext, listCrossContext and revokeCrossContext. A listing shows the contexts a token opens in contextIds; a cross-context token has no contextId of its own (0). A reseller administrator sees, and can revoke, only the tokens whose contexts it all owns.

Trying it without a deployment

The gateway can be run against a middleware on a developer machine with ./gradlew :grpc-proto:provisioningGateway, which makes the same paths available at http://localhost:8090/prov/v1/.... Useful for reproducing a report without a cluster; see grpc-api/readme.md in the middleware sources for the details.

Errors

The gateway maps the internal status onto an HTTP status and returns a JSON body with a message:

Situation HTTP
Invalid credentials 401
Object does not exist 404
Malformed or contradictory request data 400
Database update in progress — retry later 503
Storage or internal error 500
The call took longer than timeout — outcome unknown, see below 504

One wrinkle worth knowing: a missing context is reported as 404 only where the operation acts on the context from the outside — creating, listing, capabilities and the like, which authenticate the master admin and then look it up.

Operations that act inside a context — users, groups, resources, deputies — authenticate against that context, and that step fails before anything is looked up. They answer 401 for a context that does not exist, the same as for a wrong password. A caller cannot tell those two apart by status code, and giving a context id it has no rights on looks identical to giving one that is not there.

When the gateway gives up

A call that takes longer than timeout is answered with 504 and a body of upstream request timeout. The operation is not canceled: the middleware completes it, and only the outcome never reaches the caller. A 504 therefore means "unknown", not "failed" — the context may well be deleted or the user changed.

Read the object back before repeating the call. The middleware logs every such call once it completes, with the method and its outcome:

WARN  [grpc-default-executor-0] com.openexchange.provisioning.grpc.servlet.utils.AbandonedCallInterceptor.log(AbandonedCallInterceptor.java:100)
com.openexchange.grpc.provisioning.ContextService/UpdateContext finished with OK after 8095 ms, but its caller had given up on it before, e.g. the HTTP gateway on its timeout. The caller did not receive this outcome.

The same applies to any timeout between the caller and the gateway: an Ingress or load balancer that gives up first leaves the caller in the same position, so it needs at least the gateway's timeout.

Differences from SOAP

The operations are the same, but they are named and grouped differently. SOAP spells out combinations as separate operations; the HTTP API folds them into one call with parameters. Listing contexts is a single endpoint where SOAP has eight operations — pattern, paging, restriction to a database or filestore, and lookup by identifier are all parameters of GET /prov/v1/contexts.

Three behaviors differ deliberately:

  • Fetching a group without specifying one returns the context's default group.
  • Blocking and unblocking a deputy permission is one endpoint with a boolean, not two operations.
  • The reseller data of a context — its custom identifier, its owner and its restrictions — is a resource of its own at /prov/v1/contexts/{context_id}/reseller, where SOAP carries those fields in the context itself. Sending restrictions replaces them wholesale, so an empty list removes all of them; leaving the field out keeps the ones the context has. The owner is reported by identifier and name only — read the full record with GET /prov/v1/resellers/{reseller_id}.

Granting several deputy permissions at once rolls back on error: if one grant fails, those already granted in that call are revoked before the error is reported, so a failed call leaves nothing behind.