docs/ai_builder/organization/automated_provisioning.md
This page is the runbook for provisioning Reflex from an external system such as an identity provider, a Terraform run, or a scheduled reconcile script. Each piece has its own page. This one puts them in order and covers what happens when only part of the flow is wired.
A directory group is a team in Reflex, and a team holds a role on a project. You grant that role once per team. Onboarding a person is then a directory add, and there is no separate default-permissions setting to configure.
# Plan availability
Steps 1 to 3 require the Enterprise plan. Service accounts, SCIM, and granting a team a project role are refused on lower plans; if one returns a plan error, [contact sales](https://reflex.dev/pricing/). Step 4 has no plan gate, only the instance-admin requirement described there. On a self-hosted deployment that does not enforce plans, SCIM is governed by a deployment switch instead of a tier.
| Step | What it establishes | Who can do it | Surface |
|---|---|---|---|
| Credential | The identity your provisioner acts as | Organization admin | Organization settings, then the token API |
| Identity | Who exists, and which groups they are in | The SCIM token | /api/scim/v2 |
| Permissions | What a group can do on a project | The service account | /api/v1/project/{id}/teams |
| Placement | Which Kubernetes namespace a project's sandboxes run in | Instance admin | /api/v1/admin/namespace |
The base URL is your instance origin. On Reflex-hosted that is https://build.reflex.dev. On a self-hosted or on-premise install it is the origin your users reach the product at. The examples below use $REFLEX_URL.
Placement applies only to Kubernetes installs, so skip step 4 on Reflex-hosted.
Run the provisioner as a service account rather than as a person. A personal token stops working when its owner leaves the organization, which takes the provisioning system down with it.
There is no API for creating one. Open Service Accounts in the organization sidebar, select New service account, and give it the Member organization role. Service accounts cannot be organization admins.
In Manage service account, grant the account a role on every project it will provision. The role must be Admin, because managing a project's members is an admin-tier capability that no lesser role carries.
# The token carries the service account's permissions, not yours
A token bound to a service account authenticates as that account. Nothing about the person who minted it is carried along. Scripts developed with your own token work because you are an organization admin; the same scripts return 403 on every call once they use the service account's token, until the account itself holds a role on those projects.
Revoking the account's project role stops the provisioner, regardless of what the operator who created it can still do by hand.
A service account's project access is granted only from organization settings. The project members API cannot do it. Because a service account holds no organization membership row, that call is refused with user is not a member of this project's organization. The message names membership, but the fix is to grant the role in organization settings.
An organization admin makes this call once.
curl -X POST "$REFLEX_URL/api/v1/user/token" \
-H "X-API-Token: $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "idp-provisioner",
"expiration": 365,
"service_account_id": "<service-account-uuid>",
"access": {
"permissions": {"project": "write"},
"projects": "all"
}
}'
The response is the token id, and that id is the credential you send in X-API-Token. Store it in the provisioning system's secret manager. Reflex does not show it again.
service_account_id makes the credential organization-owned from the start. Without it you get a personal token tied to your own membership.access is optional, and omitting it produces an unrestricted token. The scope above is the minimum for step 3, where project: write covers reading, granting, and revoking team roles. Narrow projects from "all" to a list of project ids if the provisioner manages only some of them. Step 2 uses its own SCIM credential and step 4 cannot use this token, so neither needs anything added here.expiration is in days. There is no renewal, so plan a rotation.To convert a personal token you already use instead of issuing a new one, POST /api/v1/user/token/{token_name}/service-account with {"service_account_id": "..."} re-homes it. The secret value does not change, so a pipeline using it keeps working, and the token moves from your own token list to the service account's.
# A scoped token cannot mint tokens
A token restricted with `access` is refused by the token endpoints, as is any service-account credential. Token management stays with a human admin, so a compromised provisioning credential cannot widen itself.
Directory sync creates the people and the groups. Open Provisioning in the organization sidebar, create a token, and copy the SCIM base URL, which is your instance origin followed by /api/scim/v2.
The SCIM token is separate from the service account token. SCIM authenticates with Authorization: Bearer, the rest of the API with X-API-Token.
The service supports PATCH and filtering. It does not support bulk operations, sorting, or ETags. GET /api/scim/v2/ServiceProviderConfig is unauthenticated, so an identity provider that discovers capabilities can read it.
/UsersA User is an organization membership.
active: false) or deleting them removes the membership and revokes their tokens and their session in that organization. Re-enabling them in the directory provisions a fresh membership rather than restoring the old one./GroupsA Group is a team. The group's SCIM resource id is the team id used in step 3, and it comes back in the create response, so there is nothing extra to look up.
members array, so your identity provider never tries to remove somebody it did not add.active attribute, so DELETE is the only removal and it is permanent.members leaves membership alone. An explicit [] empties the group. A rename is a displayName change.Three team endpoints, plus the project's role listing, all authenticated with the service account's token.
Role names are per-project. List them first:
curl "$REFLEX_URL/api/v1/project/$PROJECT_ID/roles" \
-H "X-API-Token: $PROVISIONER_TOKEN"
Grant the role. The team id is the SCIM group id from step 2:
curl -X PUT "$REFLEX_URL/api/v1/project/$PROJECT_ID/teams/$TEAM_ID" \
-H "X-API-Token: $PROVISIONER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "editor"}'
{"status": "granted", "team_id": "...", "role": "editor"}
Read the current grants, which a reconciling provisioner should do before writing:
curl "$REFLEX_URL/api/v1/project/$PROJECT_ID/teams" \
-H "X-API-Token: $PROVISIONER_TOKEN"
{
"grants": [
{"team_id": "...", "team_name": "Platform", "role": "editor",
"base_tier": "editor", "permissions": [], "member_count": 12}
],
"pending": []
}
DELETE on the same path revokes.
status fieldBoth writes are idempotent. Re-granting a role a team already holds succeeds, and revoking from a team that holds nothing answers not_granted.
# `pending_approval` is a 200
If the project requires approval for member changes, a grant parks as an approval request instead of applying. The response is `{"status": "pending_approval", ...}` with HTTP 200, and nobody has the access yet. Branch on `status`, or you will record access that does not exist.
The gate is the project's approval policy for member additions and role changes, and an organization can enable it by default for every new project, so a project you never configured can behave this way.
A parked grant is absent from grants and present in pending. Read both before deciding what access exists.
Re-asserting is safe. While the gate is on, asking again for the role a pending request already describes answers pending_approval without disturbing that request or notifying the approver a second time. Asking for a different role replaces the pending request and notifies again, which is correct, because the desired state changed. If the gate has since been turned off, the next call applies the grant and clears the stale request, so a loop recovers on its own.
Revocations park the same way, and the team keeps its access until an approver accepts.
/Users before /Groups; most identity providers already do.A project's Kubernetes namespace decides which cluster tenant its sandboxes run in. Both endpoints below are instance-admin only, not part of the organization admin's surface.
# A service account can never be an instance admin
The provisioning token from step 1 does not work here. Instance admin is a property of a user account and a service account can never hold it, so these two endpoints need a token belonging to an operator who is an instance admin. On-premise, that is your own platform team.
A token carrying an access map reaches these endpoints only if it grants account-level write, which is already most of an instance admin's power, so narrower scoping is refused here even when the owner is an instance admin. Treat this credential as privileged and keep it out of the provisioning system.
curl -X POST "$REFLEX_URL/api/v1/admin/org/orgs/update-default-namespace" \
-H "X-API-Token: $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"org_id": "<org-uuid>", "namespace": "platform-sandboxes"}'
New projects inherit this value when they are created. Inheritance happens once, so changing the default later leaves existing projects where they are. A blank namespace clears the default.
curl -X PUT "$REFLEX_URL/api/v1/admin/namespace/projects/$PROJECT_ID" \
-H "X-API-Token: $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"namespace": "research-sandboxes"}'
{
"project_id": "...", "project_name": "Research", "org_id": "...",
"namespace": "research-sandboxes",
"effective_namespace": "research-sandboxes",
"status": "assigned",
"previous_namespace": "platform-sandboxes",
"applies_to": "sandboxes_created_after_this_change"
}
DELETE on the same path clears the pin, and so does a PUT with a blank namespace. GET on the path reads it back, and GET /api/v1/admin/namespace/projects?namespace=&org_id=&limit=&offset= lists projects for reconciliation.
Three parts of the response affect how a reconciler should read it:
applies_to reports that the change is not retroactive. Retargeting moves the next sandbox. Sandboxes already running keep the namespace they were created in and are still cleaned up there. Migrating running work is out of scope, since a mid-session move would drop the user's app.status is assigned, cleared, or unchanged. A re-assert answers unchanged and writes no audit row, so a reconcile loop does not fill the audit trail with its own passes.POST /api/v1/project/users/invite writes one user's role at a time. It takes a Reflex user id, works only for somebody who is already an organization member, and has to be repeated for every person on every project, which is the work a team grant removes.
Organization invitations wait for a sign-in. An invitation becomes a membership when the invited person signs in, and nothing a provisioner can poll turns it into access. SCIM /Users creates the membership directly.
Namespace has no SCIM attribute. There is no extension for it, and a directory that could set it would let whoever administers your identity provider choose which cluster tenant workloads run in.
Service accounts stay out of the directory. They are created in organization settings and hold a synthetic address under the reserved .invalid domain that no identity provider can assert, so there is nothing for the directory to manage.
Adding people to a synced team in the product hides them from the identity provider. The membership works, but your directory stops being a complete description of who has access. Add them to the group in the directory instead.
A directory group called reflex-platform-engineers should hold editor on one project.
idp-provisioner with the Member organization role.POST /api/v1/user/token and service_account_id set, as in step 1. Store it as $PROVISIONER_TOKEN.In Okta, add the SCIM application, set the base URL to $REFLEX_URL/api/scim/v2, set Authentication Mode to HTTP Header with the SCIM token as the bearer value, and enable Create Users, Update User Attributes, and Deactivate Users. Turn on Push Groups and push reflex-platform-engineers. In Microsoft Entra ID, the equivalents are the tenant URL, the secret token, and the default user and group mappings; leave the group mapping enabled.
Push a small group first and confirm it appears under Teams before rolling out the rest of the directory.
Take the group's SCIM id, which is the team id, and grant it:
curl -X PUT "$REFLEX_URL/api/v1/project/$PROJECT_ID/teams/$TEAM_ID" \
-H "X-API-Token: $PROVISIONER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "editor"}'
Check status. If it says pending_approval, the grant is parked and an approver has to accept it before anybody has access.
Adding a person to reflex-platform-engineers in the directory now gives them editor on that project, with no call against Reflex beyond the ones the identity provider already makes. Removing them from the group takes it away.
A reconciliation loop, if you run one, should:
GET /api/v1/project/{id}/teams and compare both grants and pending against desired state.pending_approval as access that does not exist yet, and do not record it as granted.| Symptom | Cause |
|---|---|
| Every call 403s after switching to the service account token | The service account does not hold a role on the project. Grant it Admin from organization settings. |
400 user is not a member of this project's organization. when adding the service account to a project | A service account holds no organization membership row. Grant its project access from organization settings instead. |
| 403 on the team grant with a mention of the plan | Granting a team project access requires the Enterprise plan. |
| A grant returns 200 but nobody has access | status is pending_approval. The project gates member changes, and an approver has to accept. |
| 400: teams cannot hold an admin-tier role | Grant admin per person; a group grant cannot carry it. |
| 400 on a SCIM create saying inactive users are not provisioned | The user is deactivated in your directory. Activate them there and let the next sync provision them. |
| A team an admin created is invisible over SCIM | /Groups lists only teams the directory created. Manage that team in the product. |
| A group emptied in the directory still shows a project role | Emptying a group does not revoke its grant. The access is dormant until the group refills. |
| 403 on the namespace endpoints with a service account token | These endpoints are instance-admin only, and a service account can never be an instance admin. Use an operator's unrestricted token. |
| A retargeted project's running sandbox is still in the old namespace | Retargeting applies to sandboxes created after the change, as applies_to reports. |
| 401 on SCIM with a working API token | SCIM uses Authorization: Bearer; the rest of the API uses X-API-Token. The two credentials are separate. |
pending_approval.