An Aspect Extension (AXL) task group —
aspect import-users <idp> — that syncs users from your identity provider into
your Aspect account in Frontegg. okta is the first provider.
It exists because the SCIM path (Okta/Auth0 → SCIM → Frontegg) isn't viable for our setup. Instead of a hosted connector, this is plain, reviewable source you drop into your own repo and run as a daily CI cron job, so your security/IT team can audit exactly what it does before adopting it.
It talks to Frontegg directly using the credential from aspect auth — no
intermediary service and no Frontegg secret to manage.
Structure: the Frontegg sync (identity, invite/deactivate, reconcile) lives
in the shared, provider-agnostic frontegg.axl; each provider is a thin reader
(okta.axl, …) that fetches and normalizes users. Adding an IdP = one reader.
Important
This applies changes by default (invite / deactivate in your Frontegg
account) — it's built to run unattended as a daily cron. Pass --dry-run to
preview the reconciliation without writing. Mutating identity from CI should
get a security-team review first.
- Reads users from the Okta admin API —
GET /api/v1/users, following Okta'sLink-header pagination — and extractsprofile.login,profile.email,firstName,lastName, andstatus. - Reconciles them against your Aspect account: active Okta users
(
ACTIVE/PROVISIONED) are invited with a role; the rest are removed. - Writes to Frontegg with the Account Admin's
aspect authtoken:- invite/create —
POST /identity/resources/users/v2(role assigned viaroleIds, invite email suppressed by default), - deactivate — look up by email, then
DELETE /identity/resources/users/v1/{id}.
- invite/create —
The target account is not a flag and not a request field. Frontegg
derives it from the frontegg-tenant-id of your signed aspect auth token, and
only honors the write because that token carries the tenant and the
account.admin role. Consequences:
- You can only ever modify the single account your token was minted for.
There is no
--account, so a misconfigured cron cannot touch the wrong account. - You must
aspect auth loginas an Account Admin of that account. - Assignable roles are capped at
viewer/admin(Frontegg keysaccount.viewer/account.admin) — never a vendor/super-admin role. Default isviewer; grantingadminrequires--role adminexplicitly. - No Frontegg vendor secret is ever handled. The client presents only the
Account Admin's own Aspect identity; Frontegg authorizes the tenant-scoped
user management from that user's
account.adminpermissions. - Invites do not email users by default. Pass
--send-invite-emailto opt in.
- The Aspect CLI on
PATH, logged in as an Account Admin (aspect auth login). - Network access to your Okta org and your Aspect/Frontegg host.
| What | How |
|---|---|
| Aspect identity | aspect auth login as an Account Admin. Read via aspect auth; the account scope rides in the signed token. No secret to store. |
OKTA_API_TOKEN |
Okta API token, read-only access to users. Sent as SSWS <token>. |
OKTA_ORG |
Okta org (acme), hostname (acme.okta.com), or URL. Or --okta-org. |
FRONTEGG_URL |
Your Aspect/Frontegg host (same domain your token was issued by, e.g. https://auth.aspect.build). Or --frontegg-url. Required for writes. |
The only secret you manage is the read-only Okta API token. Rotate it on your normal cadence.
Add the extension to your MODULE.aspect (see example/):
axl_local_dep(name = "import_users", path = "path/to/import-users", auto_use_tasks = True)Preview (read-only — pass --dry-run):
aspect auth login # as an Account Admin of the target account
export OKTA_ORG=acme
export OKTA_API_TOKEN=…
aspect import-users okta --dry-runApply (the default):
aspect import-users okta --frontegg-url=https://auth.aspect.buildFlags:
| Flag | Default | Meaning |
|---|---|---|
--okta-org |
$OKTA_ORG |
Okta org subdomain, hostname, or full URL. |
--role |
viewer |
Role on upsert: viewer or admin. |
--dry-run |
false |
Preview the reconciliation without writing. |
--frontegg-url |
$FRONTEGG_URL |
Aspect/Frontegg host. Required for writes. |
--send-invite-email |
false |
Send Frontegg's invite email (off by default). |
--profile |
default |
Aspect credential profile to use. |
GitHub Actions example. Secrets: the read-only Okta token, plus an Aspect API token (mint it for a dedicated Account Admin service identity of the account):
name: sync-okta-users
on:
schedule:
- cron: "0 7 * * *" # daily 07:00 UTC
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Aspect CLI
uses: jaxxstorm/action-install-gh-release@v2.1.0
with:
repo: aspect-build/aspect-cli
asset-name: aspect-cli
platform: unknown_linux
arch: x86_64
extension-matching: disable
rename-to: aspect
chmod: 0755
- name: Log in to Aspect (Account Admin, scopes to that account only)
run: printf '%s' "${ASPECT_API_TOKEN}" | aspect auth login --with-api-token
env:
ASPECT_API_TOKEN: ${{ secrets.ASPECT_API_TOKEN }}
- name: Sync
env:
OKTA_ORG: ${{ vars.OKTA_ORG }}
OKTA_API_TOKEN: ${{ secrets.OKTA_API_TOKEN }}
FRONTEGG_URL: ${{ vars.FRONTEGG_URL }}
run: aspect import-users okta # applies by defaultThe read + dry-run path runs end-to-end against a bundled fake Okta, so you see the exact reconciliation output without a real org or any writes.
# 1. Start the fake Okta (serves two paginated pages of users).
python3 test/mock_okta.py &
# 2. Authenticate to Aspect (the tool refuses to run otherwise).
aspect auth login
# 3. Point the tool at the mock. --dry-run previews without writing;
# OKTA_API_TOKEN can be any non-empty value.
OKTA_API_TOKEN=dummy aspect import-users okta --okta-org=http://localhost:8799 --dry-runExpected dry-run output:
Planned reconciliation for account <your-tenant> (role=viewer):
+ upsert ada@acme.com <ada@acme.com> (Ada Lovelace)
+ upsert alan@acme.com <alan@acme.com> (Alan Turing)
- deactivate old@acme.com <old@acme.com> [DEPROVISIONED]
For a real run, drop --dry-run and use --okta-org=<your-org>, a real
OKTA_API_TOKEN, and --frontegg-url=<your-host>.
Select the Aspect auth environment with __ASPECT_ENVIRONMENT__ (production
default, or staging / dev). It's resolved every command and credentials are
keyed by auth domain, so set it for both the login and the run, and use a
distinct --profile to keep it beside your prod login:
__ASPECT_ENVIRONMENT__=staging aspect auth login --profile staging
__ASPECT_ENVIRONMENT__=staging OKTA_API_TOKEN=dummy \
aspect import-users okta --okta-org=http://localhost:8799 --profile staging --dry-runLayout:
| File | Role |
|---|---|
frontegg.axl |
Shared, provider-agnostic core: aspect-auth identity, the Frontegg client (invite / find / deactivate), and the reconcile + dry-run/apply loop. Exposes reconcile() and fail(). |
okta.axl |
Okta reader: fetch + paginate + normalize, then call reconcile(). |
MODULE.aspect |
Re-exports each reader task (use_task(...)). |
To add a provider (e.g. Entra), write entra.axl:
load("./frontegg.axl", "fail", "reconcile")
def impl(ctx):
# 1. Read + paginate users from your IdP.
# 2. Normalize each to a struct with these fields:
# login, email, first_name, last_name, status, active (bool)
# 3. Hand off — the shared core does identity, diff, and the writes.
return reconcile(ctx, users)
entra = task(
group = ["import-users"], # -> `aspect import-users entra`
implementation = impl,
args = {
# provider-specific args here, plus the shared set:
"role": args.string(default = "viewer"),
"dry_run": args.boolean(default = False),
"frontegg_url": args.string(default = ""),
"send_invite_email": args.boolean(default = False),
"profile": args.string(default = ""),
},
)Then add use_task("entra.axl", "entra") to MODULE.aspect. Keep provider
specifics (auth, endpoints, field mapping) in the reader; the core stays generic.
Read + pagination + dry-run diff, aspect auth-derived account scoping, and the
write path (invite with role + deactivate, direct to Frontegg) are implemented
and validated against a live Okta trial + Frontegg account. Mutating identity
from CI should still get a security-team +1.