Provisioning users from a directory
If your team already lives in Okta, Entra ID, Google Workspace, LDAP or an HR system, you can
keep Zeabur Identity's users in step with it. Zeabur Identity has no built-in connector (it does not speak SCIM).
Instead, you run a small sync program that reads your directory and writes User objects
through the Kubernetes API.
This guide sets up:
- a service account that can write users and nothing else;
- an admission policy that limits the damage if the program is buggy or compromised;
- the sync program itself, as a shell example.
For everyday commands, see Managing users.
What the sync tool does
Section titled “What the sync tool does”| Directory event | Kubernetes write |
|---|---|
| Person added | create User (labelled as managed by the tool) |
| Name or groups changed | update spec.displayName / spec.groups |
| Person suspended | set spec.disabled: true |
| Person removed | set spec.disabled: true; an admin deletes later |
It does not:
- Create
Credentials. Linking a passkey or upstream account happens only through a one-time link the person opens, so a sync tool can never decide who signs in as whom. - Create
Invitations. An invitation link enrols a passkey on an existing user, so whoever can create invitations can take over any account. Keep that power with admins (zeabur-identityctl invite --username <name>), or give it to a separate, admin-equivalent job. - Make admin accounts. Admin rights stay a decision made by hand: an admin may make a managed user an admin account, and the tool's later updates keep its account type.
- Delete users. Removing a user safely means deleting its credentials and sessions first (see managing-users.md); disabling is enough to stop sign-ins, and is reversible.
Match people by the directory's own permanent ID, kept in an annotation on the User. Never
match by email address: addresses get reused when people leave.
1. A service account with write access to users only
Section titled “1. A service account with write access to users only”Save this as user-sync-rbac.yaml and apply it with kubectl apply -f user-sync-rbac.yaml:
apiVersion: v1kind: ServiceAccountmetadata: name: user-sync namespace: zeabur-identity---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: user-sync namespace: zeabur-identityrules:- apiGroups: [identity.zeabur.com] resources: [users] verbs: [get, list, watch, create, update, patch]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: user-sync namespace: zeabur-identityroleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: user-syncsubjects:- kind: ServiceAccount name: user-sync namespace: zeabur-identityThere is no delete, and no access to credentials, invitations, grants or Secrets.
2. An admission policy that bounds it
Section titled “2. An admission policy that bounds it”RBAC can say "may write users", but not "may write only these users, with only these groups".
A ValidatingAdmissionPolicy (Kubernetes 1.30 or later) adds those rules. It applies only to
the user-sync account, and if the policy cannot be evaluated, the write is refused.
It enforces that the sync program:
- only changes users it created (label
identity.zeabur.com/source=directory); - never makes or unmakes an admin account;
- only assigns groups from an allowlist. Edit
['dev', 'ops']below to yours; - names each object after its username (see why).
Save this as user-sync-policy.yaml and apply it with kubectl apply -f user-sync-policy.yaml:
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: zeabur-identity-user-syncspec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: [identity.zeabur.com] apiVersions: ["*"] operations: [CREATE, UPDATE] resources: [users] matchConditions: - name: user-sync expression: request.userInfo.username == 'system:serviceaccount:zeabur-identity:user-sync' variables: - name: managed expression: >- has(object.metadata.labels) && 'identity.zeabur.com/source' in object.metadata.labels && object.metadata.labels['identity.zeabur.com/source'] == 'directory' - name: wasManaged expression: >- request.operation == 'CREATE' || (has(oldObject.metadata.labels) && 'identity.zeabur.com/source' in oldObject.metadata.labels && oldObject.metadata.labels['identity.zeabur.com/source'] == 'directory') - name: admin expression: "has(object.spec.accountType) && object.spec.accountType == 'Admin'" - name: wasAdmin expression: >- request.operation == 'UPDATE' && has(oldObject.spec.accountType) && oldObject.spec.accountType == 'Admin' validations: - expression: variables.wasManaged message: user-sync may only change users it created - expression: variables.managed message: users written by user-sync must keep the label identity.zeabur.com/source=directory - expression: object.spec.username == object.metadata.name message: the object name must equal the username, so a username cannot be taken twice - expression: "!has(object.spec.groups) || object.spec.groups.all(g, g in ['dev', 'ops'])" message: user-sync may only assign allowlisted groups - expression: variables.admin == variables.wasAdmin message: user-sync never makes or unmakes admin accounts---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: zeabur-identity-user-syncspec: policyName: zeabur-identity-user-sync validationActions: [Deny]An admin can still make a synced user an admin account by hand. The sync program's later updates leave the account type alone, because server-side apply keeps fields the program does not send.
Why the name must equal the username
Section titled “Why the name must equal the username”Kubernetes sees every User as zeabur:<username>. Two objects with the same username would
be the same person to RBAC. Object names are unique, so requiring the name to equal the
username stops the program from creating a second admin.
Username rules
Section titled “Username rules”Use the same value for metadata.name and spec.username. The User schema accepts lowercase
DNS subdomains of at most 63 characters: letters, digits, - and ., with each dot-separated
label starting and ending in a letter or digit. No @, + or _. The exact pattern is:
^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$zeabur-identityctl invite and zeabur-identityctl setup-link enforce the same rule.
If your directory identifies people by email address, derive a short name that fits these
rules (for example alice.chen from alice.chen@example.com). Two people can end up with the
same short name, so the sync program must still check the directory ID before updating a user;
see the sync loop.
Test the policy
Section titled “Test the policy”Before trusting the policy, check that it refuses what it should. Both commands below must fail:
as=(--as=system:serviceaccount:zeabur-identity:user-sync)# Refused: touches a user the tool did not create.kubectl -n zeabur-identity patch zbuser admin "${as[@]}" --type=merge -p '{"spec":{"disabled":true}}'# Refused: admin account.kubectl -n zeabur-identity apply "${as[@]}" -f - <<'EOF'apiVersion: identity.zeabur.com/v1alpha1kind: Usermetadata: name: mallory namespace: zeabur-identity labels: {identity.zeabur.com/source: directory}spec: {username: mallory, accountType: Admin}EOF3. The sync loop
Section titled “3. The sync loop”Write the program in any language with a Kubernetes client. Run it as the user-sync service
account: as a CronJob in the cluster, or elsewhere with a token from
kubectl -n zeabur-identity create token user-sync.
Below is the shape of it in shell. directory-export stands for your own command that lists
everyone in the directory, one JSON object per line with id, username, name, groups and
active, and exits with an error when it cannot list everyone.
#!/usr/bin/env bashset -euo pipefailns=zeabur-identityfailed=0
# Read the whole export before writing anything: a failing exporter stops the job here.people=$(directory-export)if [[ -z "$people" ]]; then echo "the export is empty; refusing to sync" >&2 exit 1fi
while read -r person; do username=$(jq -r .username <<<"$person") jq -n --arg ns "$ns" --argjson p "$person" '{ apiVersion: "identity.zeabur.com/v1alpha1", kind: "User", metadata: { name: $p.username, namespace: $ns, labels: {"identity.zeabur.com/source": "directory"}, annotations: {"example.com/directory-id": $p.id} }, spec: { username: $p.username, displayName: $p.name, groups: $p.groups, disabled: ($p.active | not) } }' | kubectl apply --server-side --field-manager=user-sync -f - || { echo "failed: $username" >&2; failed=1; continue; } echo "synced $username"done <<<"$people"exit "$failed"Why it is written this way:
- It reads the whole export before writing anything. If the export fails half-way, the job
stops before changing anyone, instead of quietly skipping the rest (and their suspensions).
Use whatever completeness check your directory offers, such as a total count or an
end-of-list marker, inside
directory-export. - It refuses an empty export. Once the program disables users missing from the export, an empty export would disable everyone.
- One failed user does not stop the others. A suspension further down the list still has to be written. The loop reports each failure and exits with an error at the end, so the job shows as failed.
The program owns displayName, groups and disabled on the users it manages. Change those in
the directory, not with kubectl: a hand edit makes the next sync fail for that user with a
field conflict. The account type is the exception, since the program never writes it.
Before applying, a real tool should also:
- Refuse to change the directory-ID annotation of an existing user, so a renamed or recycled
account in the directory cannot inherit someone else's
User. - Disable managed users that are no longer in the export
(
kubectl get zbuser -l identity.zeabur.com/source=directory). - Lowercase usernames and check them against the username rules before writing.
4. Send new people their links
Section titled “4. Send new people their links”The sync program creates users, but they still need a one-time link to set up a passkey. An
admin sends it, as in Add someone. The link works only if the
Zeabur Identity server runs with --invitations; see
If the link does not work.
export ZEABUR_IDENTITY_ISSUER=https://identity.example.test # your issuerzeabur-identityctl invite --username <name>